From 1603ff82115d2a16ae86c1fc9bf109ea240af957 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 27 Aug 2026 22:54:59 +0300 Subject: [PATCH] Update group booking and package --- apps/edr-passenger-api/package.json | 1 + .../migration.sql | 2 + .../migration.sql | 2 + apps/edr-passenger-api/prisma/schema.prisma | 2 + apps/edr-passenger-api/src/main.ts | 18 +- .../modules/bookings/bookings.controller.ts | 14 +- .../modules/bookings/guest-booking.service.ts | 7 +- .../packages/package-image-upload.options.ts | 44 ++ .../modules/packages/packages.controller.ts | 29 +- .../src/modules/packages/packages.service.ts | 57 ++- .../src/modules/schedules/schedules.dto.ts | 6 +- .../modules/schedules/schedules.service.ts | 3 + .../src/modules/search/search.dto.ts | 7 + .../src/modules/search/search.service.ts | 58 ++- .../src/modules/seats/seats.controller.ts | 5 +- .../src/modules/seats/seats.dto.ts | 7 + .../src/modules/seats/seats.service.ts | 2 + .../backoffice/src/app/group-booking/page.tsx | 465 +++++++++++++++--- .../backoffice/src/app/login/page.tsx | 13 +- .../src/app/package-bookings/page.tsx | 3 +- .../backoffice/src/app/packages/page.tsx | 169 ++++++- .../src/app/reset-password/page.tsx | 4 +- .../backoffice/src/app/schedules/page.tsx | 101 +++- .../backoffice/src/lib/api-client.ts | 38 ++ .../backoffice/src/lib/api/group-booking.ts | 47 +- .../backoffice/src/lib/api/index.ts | 9 + .../src/lib/import/passenger-excel.ts | 41 +- apps/edr-passenger-web/portal/next.config.js | 18 + .../src/app/booking/passengers/page.tsx | 2 +- .../portal/src/app/booking/results/page.tsx | 6 + .../portal/src/app/booking/review/page.tsx | 50 +- .../portal/src/app/packages/[id]/page.tsx | 3 +- .../portal/src/components/PackagesSection.tsx | 21 +- packages/types/src/passenger/index.ts | 8 +- pnpm-lock.yaml | 285 ++--------- 35 files changed, 1170 insertions(+), 377 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260827153613_add_schedule_is_group_booking_only/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260827205217_add_travel_package_image_url/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/packages/package-image-upload.options.ts diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 230a44529..c048ef1e9 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -59,6 +59,7 @@ "helmet": "^8.0.0", "jose": "^5.10.0", "minio": "7.1.3", + "multer": "^2.1.1", "pg": "^8.21.0", "qrcode": "^1.5.3", "reflect-metadata": "^0.2.2", diff --git a/apps/edr-passenger-api/prisma/migrations/20260827153613_add_schedule_is_group_booking_only/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260827153613_add_schedule_is_group_booking_only/migration.sql new file mode 100644 index 000000000..ffcbd67aa --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260827153613_add_schedule_is_group_booking_only/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TrainSchedule" ADD COLUMN IF NOT EXISTS "isGroupBookingOnly" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/edr-passenger-api/prisma/migrations/20260827205217_add_travel_package_image_url/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260827205217_add_travel_package_image_url/migration.sql new file mode 100644 index 000000000..f0f9f8cd6 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260827205217_add_travel_package_image_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TravelPackage" ADD COLUMN IF NOT EXISTS "imageUrl" TEXT; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 26f93bd43..22bfa8f40 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -372,6 +372,7 @@ model TrainSchedule { carbonRating String @default("A") notes String? isPackageOnly Boolean @default(false) + isGroupBookingOnly Boolean @default(false) train Train @relation(fields: [trainId], references: [id]) route Route? @relation(fields: [routeId], references: [id]) originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) @@ -1529,6 +1530,7 @@ model TravelPackage { code String @unique name String description String? + imageUrl String? status PackageStatus @default(DRAFT) outboundScheduleId String returnScheduleId String diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 486094c3f..d0239dae7 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -4,9 +4,11 @@ import "dotenv/config"; import "reflect-metadata"; import { NestFactory } from "@nestjs/core"; +import { NestExpressApplication } from "@nestjs/platform-express"; import { Logger, ValidationPipe, VersioningType } from "@nestjs/common"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import helmet from "helmet"; +import { join } from "path"; import { AppModule } from "./app.module"; import { HttpExceptionFilter } from "./common/filters/http-exception.filter"; import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor"; @@ -23,10 +25,20 @@ if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS === async function bootstrap() { // rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed. - const app = await NestFactory.create(AppModule, { rawBody: true }); + const app = await NestFactory.create(AppModule, { rawBody: true }); - // Security headers - app.use(helmet()); + // Security headers. crossOriginResourcePolicy defaults to 'same-origin' in helmet, which + // would make browsers refuse to actually render package images (served from this origin) + // inside tags on the portal/backoffice (different origins) even though the request + // itself succeeds — relaxed to 'cross-origin' since this API already serves all its JSON to + // those exact same origins per the CORS allowlist below; nothing new is being exposed. + app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); + + // Serves apps/edr-passenger-api/public/* at the site root — package images live at + // public/uploads/packages/, reachable as GET /uploads/packages/. Local-disk + // storage is a deliberate, explicit stopgap (see packages.service.ts's uploadImage) rather + // than this app's usual MinIO-backed upload pattern (see modules/support's attachments). + app.useStaticAssets(join(__dirname, "..", "public")); // URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under // `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 6bfc042d8..2aff404b8 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -368,26 +368,22 @@ export class BookingsController { summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group", description: `Staff-only entry point for bulk/group bookings (e.g. tour groups booked via an uploaded passenger list and auto-assigned seats from POST /seats/auto-assign-hold). -Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff and always ONE_WAY. +Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff. Supports ONE_WAY (default) and ROUND_TRIP via \`bookingType\`; for ROUND_TRIP, supply \`returnScheduleId\`/\`returnHoldId\`/\`returnOriginStationId\`/\`returnDestinationStationId\`/\`returnSeatClassId\` and each passenger's \`returnSeatId\`, exactly as POST /bookings/guest does. Skips Verifayda national-ID verification: the roster comes from a staff-uploaded spreadsheet, not a live Fayda identity flow, so there is nothing to verify an ID number against. Passenger fields (name, DOB, nationality) are trusted exactly as uploaded. Deliberately does NOT forward the staff caller's identity into booking creation: the acting staff member is not a Passenger, so the underlying guest-booking flow (which tries to resolve an authenticated caller as an existing Passenger profile) would reject the request. The booking is created exactly like a guest booking — a fresh passenger record, contact info from the first passenger in the list — with staff authorization enforced only at this route. -If booking creation fails after the seats were already held, the hold is released immediately so the seats don't sit locked for the rest of the hold TTL.`, +If booking creation fails after the seats were already held, every hold involved (outbound and, for ROUND_TRIP, return) is released immediately so the seats don't sit locked for the rest of the hold TTL.`, }) @ApiResponse({ status: 201, description: "Group booking created successfully with fareBreakdown" }) @ApiResponse({ status: 400, description: "Missing required seat IDs" }) async createGroup(@Body() dto: CreateGuestBookingDto) { try { - return await this.guestService.createGuestBooking({ ...dto, bookingType: "ONE_WAY", skipIdentityVerification: true }); + return await this.guestService.createGuestBooking({ ...dto, bookingType: dto.bookingType || "ONE_WAY", skipIdentityVerification: true }); } catch (err) { - try { - await this.seatsService.releaseHold(dto.holdId); - } catch (releaseErr) { - // Best-effort — the hold may already be gone (e.g. it expired mid-request). The - // original booking-creation error is what the caller actually needs to see. - } + const holdIdsToRelease = [dto.holdId, dto.returnHoldId].filter((id): id is string => !!id); + await Promise.allSettled(holdIdsToRelease.map((id) => this.seatsService.releaseHold(id))); throw err; } } diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index a887df62a..133d289e3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -392,7 +392,10 @@ export class GuestBookingService { contactEmail: contact.contactEmail, contactPhone: contact.contactPhone, seats: { - create: passengersWithFares.map((p) => ({ + // A free child (no seatId — see passengersWithFares above) has nothing to connect to; + // `connect: { id: undefined }` throws PrismaClientValidationError immediately if this + // filter is missing, so it's never optional here despite the map below looking safe. + create: passengersWithFares.filter((p) => p.seatId).map((p) => ({ seat: { connect: { id: p.seatId } }, scheduleId: dto.scheduleId, passengerName: p.passengerName, @@ -763,7 +766,7 @@ export class GuestBookingService { passenger.idDocumentType === IdDocumentType.NATIONAL_ID; if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { - if (passenger.idDocumentNumber) { + if (passenger.idDocumentNumber && !dto.skipIdentityVerification) { const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`); passengerName = verification.passengerData?.fullName || passengerName; diff --git a/apps/edr-passenger-api/src/modules/packages/package-image-upload.options.ts b/apps/edr-passenger-api/src/modules/packages/package-image-upload.options.ts new file mode 100644 index 000000000..7d9a6d33b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/packages/package-image-upload.options.ts @@ -0,0 +1,44 @@ +import { BadRequestException } from '@nestjs/common'; +import { diskStorage } from 'multer'; +import { randomUUID } from 'crypto'; +import { extname, join } from 'path'; +import { mkdirSync } from 'fs'; +import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; + +/** Multipart field name carrying the package image file. */ +export const PACKAGE_IMAGE_FIELD = 'image'; + +/** Local-disk stopgap (see packages.service.ts) — not this app's usual MinIO-backed upload pattern. */ +export const PACKAGE_IMAGE_UPLOAD_DIR = join(__dirname, '..', '..', '..', 'public', 'uploads', 'packages'); + +export const PACKAGE_IMAGE_MAX_BYTES = 5 * 1024 * 1024; // 5MB + +const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']); + +export const packageImageMulterOptions: MulterOptions = { + storage: diskStorage({ + destination: (_req, _file, cb) => { + // mkdir on every request rather than once at module load — this directory is + // gitignored (see public/uploads/packages/.gitignore) so a fresh checkout/deploy + // won't have it yet, and recursive mkdir on an already-existing dir is a no-op. + mkdirSync(PACKAGE_IMAGE_UPLOAD_DIR, { recursive: true }); + cb(null, PACKAGE_IMAGE_UPLOAD_DIR); + }, + // Unique filename so two packages (or two uploads for the same package) never collide — + // never trust or reuse the original filename. + filename: (_req, file, cb) => { + cb(null, `${randomUUID()}${extname(file.originalname).toLowerCase()}`); + }, + }), + limits: { + fileSize: PACKAGE_IMAGE_MAX_BYTES, + files: 1, + }, + fileFilter: (_req, file, cb) => { + if (!ALLOWED_MIME_TYPES.has(file.mimetype)) { + cb(new BadRequestException('Image must be JPEG, PNG, WEBP, or GIF.'), false); + return; + } + cb(null, true); + }, +}; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index 79f04f9d9..6ef20bf40 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -1,8 +1,10 @@ -import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, UseInterceptors, UploadedFile, Request, Query, BadRequestException } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiConsumes, ApiBody } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PackagesService } from './packages.service'; import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto'; +import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; @@ -148,6 +150,29 @@ export class PackagesController { return this.service.remove(id, cascade === 'true'); } + @Post(':id/image') + @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions)) + @ApiConsumes('multipart/form-data') + @ApiBody({ schema: { type: 'object', properties: { [PACKAGE_IMAGE_FIELD]: { type: 'string', format: 'binary' } } } }) + @ApiOperation({ + summary: 'Upload or replace a package image (admin)', + description: 'JPEG/PNG/WEBP/GIF, max 5MB. Replaces and deletes the previous image file if one exists — works the same whether the package currently has an image or not, so this one route covers both the initial upload and later replacement.', + }) + uploadImage(@Param('id') id: string, @UploadedFile() file?: Express.Multer.File) { + if (!file) throw new BadRequestException('No image file provided.'); + return this.service.uploadImage(id, file); + } + + @Delete(':id/image') + @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' }) + removeImage(@Param('id') id: string) { + return this.service.removeImage(id); + } + @Patch(':id/activate') @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index e16f8f025..49f5e4f88 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { CurrencyService } from '../currency/currency.service'; import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto'; @@ -7,6 +7,9 @@ import { BookingsService } from '../bookings/bookings.service'; import { GuestBookingService } from '../bookings/guest-booking.service'; import { AuditService } from '../../common/audit.service'; import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils'; +import { PACKAGE_IMAGE_UPLOAD_DIR } from './package-image-upload.options'; +import { join } from 'path'; +import { unlink } from 'fs/promises'; /** Package-specific fare rules */ const PKG_MAX_ADULTS = 5; @@ -46,6 +49,8 @@ function generateRef(): string { @Injectable() export class PackagesService { + private readonly logger = new Logger(PackagesService.name); + constructor( private readonly prisma: PrismaService, private readonly currencyService: CurrencyService, @@ -325,6 +330,55 @@ export class PackagesService { return updated; } + /** Public URL prefix main.ts's app.useStaticAssets serves public/uploads/packages under. */ + private readonly PACKAGE_IMAGE_URL_PREFIX = '/uploads/packages/'; + + private packageImagePublicUrl(filename: string): string { + const base = (process.env.APP_PUBLIC_URL || `http://localhost:${process.env.PORT || 4000}`).replace(/\/$/, ''); + return `${base}${this.PACKAGE_IMAGE_URL_PREFIX}${filename}`; + } + + /** Best-effort delete of the file backing a package's current imageUrl — never throws, since a + * missing file (already deleted, moved, or from before this feature existed) shouldn't block + * the DB update that's actually replacing/clearing the field. */ + private async deletePackageImageFile(imageUrl: string | null): Promise { + if (!imageUrl) return; + const idx = imageUrl.indexOf(this.PACKAGE_IMAGE_URL_PREFIX); + if (idx === -1) return; // not a file this app manages (e.g. an external URL) — nothing to delete + const filename = imageUrl.slice(idx + this.PACKAGE_IMAGE_URL_PREFIX.length); + if (!filename || filename.includes('/') || filename.includes('..')) return; // defensive: never touch paths outside the uploads dir + try { + await unlink(join(PACKAGE_IMAGE_UPLOAD_DIR, filename)); + } catch (err: any) { + if (err?.code !== 'ENOENT') this.logger.warn(`Failed to delete package image file ${filename}: ${err?.message ?? err}`); + } + } + + /** Multer's diskStorage has already written the file to PACKAGE_IMAGE_UPLOAD_DIR by the time + * this runs (see package-image-upload.options.ts) — this just points the package at it and + * cleans up whatever it's replacing. */ + async uploadImage(id: string, file: Express.Multer.File) { + const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } }); + if (!pkg) throw new NotFoundException('Package not found'); + + const imageUrl = this.packageImagePublicUrl(file.filename); + const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl } }); + await this.deletePackageImageFile(pkg.imageUrl); + await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl } }); + return updated; + } + + async removeImage(id: string) { + const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } }); + if (!pkg) throw new NotFoundException('Package not found'); + if (!pkg.imageUrl) return this.prisma.travelPackage.findUnique({ where: { id } }); + + const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl: null } }); + await this.deletePackageImageFile(pkg.imageUrl); + await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl: null } }); + return updated; + } + async addTier(packageId: string, dto: CreatePriceTierDto) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } }); if (!pkg) throw new NotFoundException('Package not found'); @@ -369,6 +423,7 @@ export class PackagesService { await this.prisma.packageInquiry.deleteMany({ where: { packageId: id } }); await this.prisma.packagePriceTier.deleteMany({ where: { packageId: id } }); await this.prisma.travelPackage.delete({ where: { id } }); + await this.deletePackageImageFile(pkg.imageUrl); await this.auditService.log({ action: 'DELETE', entityType: 'Package', entityId: id }); return { deleted: true }; } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 333eb7d26..1d0a611e4 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator'; +import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, IsBoolean, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -56,6 +56,9 @@ export class CreateScheduleDto { @ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' }) @IsOptional() @IsArray() @IsString({ each: true }) coachIds?: string[]; + + @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() @IsBoolean() isPackageOnly?: boolean; + @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean; } export class UpdateScheduleDto { @@ -64,6 +67,7 @@ export class UpdateScheduleDto { @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; @ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>; @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean; + @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean; } export class UpdateStopTimeDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 838b2bf49..cd3157ef8 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -263,6 +263,8 @@ export class SchedulesService { arrivalAt: arr, durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000), stopsCount: Math.max(0, route.stops.length - 2), + isPackageOnly: dto.isPackageOnly ?? false, + isGroupBookingOnly: dto.isGroupBookingOnly ?? false, }, include: { train: true, originStation: true, destinationStation: true }, }); @@ -1000,6 +1002,7 @@ export class SchedulesService { if (dto.status) updateData.status = dto.status; if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly; + if (dto.isGroupBookingOnly !== undefined) updateData.isGroupBookingOnly = dto.isGroupBookingOnly; if (Object.keys(updateData).length > 0) { await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index 1c99ce418..8b5a4f5ee 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -27,6 +27,13 @@ export class SearchTripsDto { @ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' }) @IsOptional() @IsDateString() returnDate?: string; + + @ApiPropertyOptional({ + example: 'PORTAL', + enum: ['PORTAL', 'GROUP_BOOKING'], + description: 'Calling surface. Omit or PORTAL for normal ticket search (default) — only sees schedules with isGroupBookingOnly=false. GROUP_BOOKING sees only schedules with isGroupBookingOnly=true — the two are an exclusive partition, not additive; each channel sees a disjoint set of schedules.', + }) + @IsOptional() @IsEnum(['PORTAL', 'GROUP_BOOKING']) channel?: string; } export class AvailableDatesQueryDto { diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 262ddd3a6..f32cefc92 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -82,6 +82,10 @@ export class SearchService { ) {} async searchTrips(dto: SearchTripsDto) { + // GROUP_BOOKING is the staff-only bulk-booking wizard's own calling surface — isGroupBookingOnly + // is an exclusive partition, not additive: this channel sees ONLY schedules explicitly created + // for group booking, and the normal ticket channel (the default, PORTAL) sees only the rest. + const forGroupBooking = dto.channel === "GROUP_BOOKING"; const [direct, transit] = await Promise.all([ this.searchSchedules( dto.originStationId, @@ -90,6 +94,7 @@ export class SearchService { dto.adultCount, dto.childCount, dto.nationality, + forGroupBooking, ), this.searchTransitOptions( dto.originStationId, @@ -98,6 +103,7 @@ export class SearchService { dto.adultCount, dto.childCount, dto.nationality, + forGroupBooking, ), ]); @@ -112,8 +118,9 @@ export class SearchService { dto.adultCount, dto.childCount, dto.nationality, + forGroupBooking, ), - this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date), + this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking), ]); return { journeyType: "ONE_WAY", @@ -133,6 +140,7 @@ export class SearchService { dto.adultCount, dto.childCount, dto.nationality, + forGroupBooking, ), this.searchTransitOptions( dto.destinationStationId, @@ -141,6 +149,7 @@ export class SearchService { dto.adultCount, dto.childCount, dto.nationality, + forGroupBooking, ), ]); @@ -172,6 +181,7 @@ export class SearchService { dto.adultCount, dto.childCount, dto.nationality, + forGroupBooking, ) : Promise.resolve([]), inbound.length === 0 @@ -182,13 +192,14 @@ export class SearchService { dto.adultCount, dto.childCount, dto.nationality, + forGroupBooking, ) : Promise.resolve([]), outbound.length === 0 - ? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date) + ? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking) : Promise.resolve(undefined), inbound.length === 0 - ? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate) + ? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate, forGroupBooking) : Promise.resolve(undefined), ]); return { @@ -223,6 +234,7 @@ export class SearchService { adultCount: number, childCount?: number, nationality?: string, + forGroupBooking = false, ) { // Real millisecond arithmetic, not string-padded day-of-month increment — the latter // produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month. @@ -241,6 +253,10 @@ export class SearchService { const baseWhere: Prisma.TrainScheduleWhereInput = { status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, + // Group Booking's search is exclusive, not additive: staff only ever see schedules + // explicitly created for group booking, never the normal passenger-facing ones, and the + // portal never sees group-only ones. Each channel is a strict partition of the other. + isGroupBookingOnly: forGroupBooking, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, }; @@ -310,6 +326,7 @@ export class SearchService { adultCount: number, childCount?: number, nationality?: string, + forGroupBooking = false, ) { // Real millisecond arithmetic, not string-padded day-of-month increment — the latter // produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month. @@ -329,6 +346,8 @@ export class SearchService { // statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere). status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, + // Exclusive partition — see the comment on searchAlternatives' baseWhere. + isGroupBookingOnly: forGroupBooking, departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, @@ -363,6 +382,7 @@ export class SearchService { originStationId: string, destinationStationId: string, dateStr: string, + forGroupBooking = false, ): Promise { const [origin, destination] = await Promise.all([ this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }), @@ -393,6 +413,7 @@ export class SearchService { select: { status: true, isPackageOnly: true, + isGroupBookingOnly: true, departureAt: true, route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } }, @@ -409,13 +430,20 @@ export class SearchService { if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate); // 3. Schedules exist that date — narrow to ones that would otherwise be bookable - // (right status, not package-only, has at least one coach assigned). - const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s)); + // (right status, not package-only, not group-booking-only unless this IS a group-booking + // search, has at least one coach assigned). + const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s, forGroupBooking)); if (bookable.length === 0) { if (sameDayForPair.every((s) => s.status === "CANCELLED")) return withCode(Passenger.SearchEmptyReasonCode.Cancelled); if (sameDayForPair.every((s) => s.isPackageOnly)) return withCode(Passenger.SearchEmptyReasonCode.PackageOnly); + // isGroupBookingOnly is an exclusive partition (see isBookableSchedule) — this same reason + // code covers both directions: the portal finding only group-reserved schedules, and Group + // Booking finding only normal ones (nothing set up for it on this date). The frontend picks + // the right copy per caller. + if (sameDayForPair.every((s) => s.isGroupBookingOnly !== forGroupBooking)) + return withCode(Passenger.SearchEmptyReasonCode.GroupBookingOnly); return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate); } @@ -483,11 +511,22 @@ export class SearchService { /** Bounds the stop-time fallback scan — connectivity is a yes/no, not a survey. */ private readonly ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT = 200; - /** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */ - private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean { + /** + * Status/package/group-booking/coach bookability only — ignores date, cutoff, and seat-level + * availability. `forGroupBooking` defaults false so existing single-arg callers (e.g. + * getAvailableDates, the portal's calendar) keep hiding group-booking-only schedules. + * isGroupBookingOnly is an exclusive partition, not an additive one: a schedule is bookable + * for a given channel only when its flag exactly matches that channel (normal schedules for + * the portal, group-only schedules for Group Booking — never both from one channel). + */ + private isBookableSchedule( + s: { status: string; isPackageOnly: boolean; isGroupBookingOnly: boolean; coachAssignments: { id: string }[] }, + forGroupBooking = false, + ): boolean { return ( (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && !s.isPackageOnly && + s.isGroupBookingOnly === forGroupBooking && s.coachAssignments.length > 0 ); } @@ -542,6 +581,7 @@ export class SearchService { departureAt: true, status: true, isPackageOnly: true, + isGroupBookingOnly: true, route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } }, }, @@ -581,6 +621,7 @@ export class SearchService { adultCount: number, childCount?: number, nationality?: string, + forGroupBooking = false, ) { // Real millisecond arithmetic, not string-padded day-of-month increment — the latter // produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month. @@ -599,6 +640,8 @@ export class SearchService { // BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere. status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, + // Exclusive partition — see the comment on searchAlternatives' baseWhere. + isGroupBookingOnly: forGroupBooking, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, @@ -609,6 +652,7 @@ export class SearchService { where: { status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, + isGroupBookingOnly: forGroupBooking, departureAt: { gte: dayStart, lt: leg2WindowEnd }, coachAssignments: { some: {} }, }, diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 2f95b22e6..87bb026d3 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -191,10 +191,12 @@ This makes it clear which segment of the route each seat is held for, enabling s @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only", - description: `Picks the requested number of available seats of the given class (preferring a contiguous row) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted. + description: `Picks the requested number of available seats of the given class (filling Lower berths first, then Middle, then Upper, ascending seat number within each tier) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted. No manual seat selection — this is for bulk/group booking flows where staff upload a passenger list rather than picking seats on a seat map. Returns the same hold shape as POST /seats/hold. +For a round-trip group booking, call this twice — once per leg — passing \`journeyDirection: 'OUTBOUND'\`/\`'RETURN'\` so a same-schedule turnaround round trip isn't mistaken for a double-hold conflict. + Throws 409 with no partial hold created if fewer than the requested seats are available in that class.`, }) @ApiResponse({ status: 201, description: "Seats auto-assigned and held" }) @@ -207,6 +209,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av dto.destinationStationId, dto.seatClassName, passengerCount, + dto.journeyDirection, ); } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts index 6e135b853..ba5a1a6ad 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts @@ -67,6 +67,13 @@ export class AutoAssignHoldDto { @ApiPropertyOptional({ example: 1, minimum: 0, description: 'Number of child passengers to assign seats for.' }) @IsOptional() @IsInt() @Min(0) childCount?: number; + + @ApiPropertyOptional({ + enum: JourneyDirection, + example: JourneyDirection.OUTBOUND, + description: 'Round-trip leg direction — OUTBOUND or RETURN. Omit for a plain one-way group booking (the hold defaults to ONE_WAY), preserving current behavior.', + }) + @IsOptional() @IsEnum(JourneyDirection) journeyDirection?: JourneyDirection; } export class ReleaseHoldDto { diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index e6256c28a..f39da4e80 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -920,6 +920,7 @@ export class SeatsService { destinationStationId: string, seatClassName: string, passengerCount: number, + journeyDirection?: JourneyDirection, ) { const seatIds = await this.autoAssignSeats(scheduleId, passengerCount, seatClassName); // Scope the synthetic passengerId to this attempt (not just its row index) — a fixed @@ -933,6 +934,7 @@ export class SeatsService { scheduleId, originStationId, destinationStationId, + journeyDirection, passengers, } as HoldSeatsDto); } diff --git a/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx b/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx index f77980c78..3f888771b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx @@ -23,7 +23,7 @@ import { type SupportedPaymentMethod, } from '@/lib/api/group-booking'; import { buildPassengerTemplate } from '@/lib/export/passenger-template'; -import { countByType, parsePassengerExcel, type ParsedPassengerRow } from '@/lib/import/passenger-excel'; +import { countByType, parsePassengerExcel, resolveFreeChildIndexes, type ParsedPassengerRow } from '@/lib/import/passenger-excel'; import ActionButton from '@/components/ui/ActionButton'; import DatePicker from '@/components/ui/DatePicker'; import Skeleton from '@/components/ui/Skeleton'; @@ -69,6 +69,11 @@ function emptySearchMessage(reason: SearchEmptyReason | undefined): string { return `Every departure from ${o} to ${d} on this date was cancelled.`; case 'PACKAGE_ONLY': return `Departures on this date are reserved for travel packages, not regular ticketing.`; + case 'GROUP_BOOKING_ONLY': + // isGroupBookingOnly is an exclusive partition — this page only ever sees group-booking + // schedules, so an empty result here means a regular (non-group) train runs on this date + // but nothing has been set up for group booking specifically. + return `A regular train runs from ${o} to ${d} on this date, but no schedule has been set up for group booking yet — ask fleet/schedule management to create one, or try another date.`; case 'CHECKIN_CLOSED': return `Check-in has already closed for every departure on this date.`; case 'FULLY_BOOKED': @@ -246,6 +251,8 @@ function GroupBookingPageContent() { const [originStationId, setOriginStationId] = useState(''); const [destinationStationId, setDestinationStationId] = useState(''); const [travelDate, setTravelDate] = useState(''); + const [tripType, setTripType] = useState<'ONE_WAY' | 'ROUND_TRIP'>('ONE_WAY'); + const [returnDate, setReturnDate] = useState(''); const [adultCount, setAdultCount] = useState(1); const [childCount, setChildCount] = useState(0); const [searchTouched, setSearchTouched] = useState(false); @@ -265,7 +272,7 @@ function GroupBookingPageContent() { const totalPassengers = (adultCount || 0) + (childCount || 0); const searchValid = !!originStationId && !!destinationStationId && originStationId !== destinationStationId - && !!travelDate && totalPassengers > 0; + && !!travelDate && totalPassengers > 0 && (tripType === 'ONE_WAY' || !!returnDate); const searchMutation = useMutation({ mutationFn: () => @@ -275,14 +282,19 @@ function GroupBookingPageContent() { date: travelDate, adultCount: adultCount || 0, childCount: childCount || 0, - journeyType: 'ONE_WAY', + journeyType: tripType, + returnDate: tripType === 'ROUND_TRIP' ? returnDate : undefined, nationality: fareTier === 'LOCAL' ? 'Ethiopian' : 'Other', + channel: 'GROUP_BOOKING', }), }); const runSearch = () => { setSearchTouched(true); if (!searchValid) return; + setResultsPhase('outbound'); + setSelectedReturnSchedule(null); + setSelectedReturnClass(null); setStep('results'); searchMutation.mutate(); }; @@ -290,13 +302,23 @@ function GroupBookingPageContent() { // ── Step 2: results / class selection ─────────────────────────────────── const [selectedSchedule, setSelectedSchedule] = useState(null); const [selectedClass, setSelectedClass] = useState(null); + // Round trip only — the outbound/return picks happen as two phases of this same step, + // mirroring the passenger portal's results page (search once, pick outbound, then return). + const [resultsPhase, setResultsPhase] = useState<'outbound' | 'return'>('outbound'); + const [selectedReturnSchedule, setSelectedReturnSchedule] = useState(null); + const [selectedReturnClass, setSelectedReturnClass] = useState(null); const seatClassId = useMemo(() => { if (!selectedClass) return null; return seatClassOptions.find((sc) => sc.name === selectedClass.className)?.id ?? null; }, [selectedClass, seatClassOptions]); - const chooseClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => { + const returnSeatClassId = useMemo(() => { + if (!selectedReturnClass) return null; + return seatClassOptions.find((sc) => sc.name === selectedReturnClass.className)?.id ?? null; + }, [selectedReturnClass, seatClassOptions]); + + const chooseOutboundClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => { setSelectedSchedule(schedule); setSelectedClass({ scheduleId: schedule.scheduleId, @@ -306,6 +328,26 @@ function GroupBookingPageContent() { displayCurrency: cls.displayCurrency, available: totalAvailable, }); + if (tripType === 'ROUND_TRIP') { + // Force re-confirming the return leg if staff changes their mind on outbound later. + setSelectedReturnSchedule(null); + setSelectedReturnClass(null); + setResultsPhase('return'); + } else { + setStep('passengers'); + } + }; + + const chooseReturnClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => { + setSelectedReturnSchedule(schedule); + setSelectedReturnClass({ + scheduleId: schedule.scheduleId, + className: cls.name, + category, + fareMinor: cls.baseFareMinor, + displayCurrency: cls.displayCurrency, + available: totalAvailable, + }); setStep('passengers'); }; @@ -322,6 +364,16 @@ function GroupBookingPageContent() { const countMismatch = passengerRows.length > 0 && (uploadedAdults !== adultCount || uploadedChildren !== childCount); const passengersValid = passengerRows.length > 0 && fileErrors.length === 0 && !rowsHaveErrors && !countMismatch; + // One-way only — the portal's own "1 free child per adult, no seat" rule (fare-utils.ts's + // isFirstChild). Round trip can't offer this: createGuestRoundTripBooking hard-requires a + // returnSeatId on every passenger, so every child there still needs a real seat both ways. + const freeChildFlags = useMemo( + () => (tripType === 'ONE_WAY' ? resolveFreeChildIndexes(passengerRows, adultCount) : passengerRows.map(() => false)), + [passengerRows, adultCount, tripType], + ); + const freeChildrenCount = freeChildFlags.filter(Boolean).length; + const paidChildrenCount = uploadedChildren - freeChildrenCount; + const downloadTemplate = async () => { if (!selectedSchedule || !selectedClass) return; const blob = await buildPassengerTemplate({ @@ -370,25 +422,52 @@ function GroupBookingPageContent() { // ── Step 4: auto-assign + hold ─────────────────────────────────────────── const [assignError, setAssignError] = useState(null); const [hold, setHold] = useState(null); + const [returnHold, setReturnHold] = useState(null); const autoAssignMutation = useMutation({ - mutationFn: () => { + mutationFn: async () => { if (!selectedSchedule || !selectedClass) throw new Error('No schedule/class selected'); - return groupBookingApi.autoAssignHold({ + // One-way's free children (see freeChildFlags above) need no seat at all — only ask for + // seats covering adults + paid children. Round trip can't offer that (every passenger + // needs both a seatId and a returnSeatId), so it still requests one seat per child. + const outboundHold = await groupBookingApi.autoAssignHold({ scheduleId: selectedSchedule.scheduleId, originStationId: selectedSchedule.origin.id, destinationStationId: selectedSchedule.destination.id, seatClassName: selectedClass.className, adultCount, - childCount, + childCount: tripType === 'ONE_WAY' ? paidChildrenCount : childCount, + journeyDirection: tripType === 'ROUND_TRIP' ? 'OUTBOUND' : undefined, }); + if (tripType !== 'ROUND_TRIP') return { outboundHold, returnHold: null }; + if (!selectedReturnSchedule || !selectedReturnClass) throw new Error('No return schedule/class selected'); + try { + const returnHoldResp = await groupBookingApi.autoAssignHold({ + scheduleId: selectedReturnSchedule.scheduleId, + originStationId: selectedReturnSchedule.origin.id, + destinationStationId: selectedReturnSchedule.destination.id, + seatClassName: selectedReturnClass.className, + adultCount, + childCount, + journeyDirection: 'RETURN', + }); + return { outboundHold, returnHold: returnHoldResp }; + } catch (err) { + // Return leg failed after outbound already succeeded — release the outbound hold + // immediately instead of leaving it locked for the rest of the hold TTL. + await groupBookingApi.releaseHold(outboundHold.holdId).catch(() => {}); + throw err; + } }, - onSuccess: (data) => { - setHold(data); + onSuccess: ({ outboundHold, returnHold: returnHoldResp }) => { + setHold(outboundHold); + setReturnHold(returnHoldResp); setAssignError(null); setStep('confirm'); }, onError: (err: any) => { + setHold(null); + setReturnHold(null); setAssignError(err?.response?.data?.message ?? err?.message ?? 'Not enough seats are available for this class.'); }, }); @@ -399,14 +478,23 @@ function GroupBookingPageContent() { autoAssignMutation.mutate(); }; - // Pairs each validated passenger row (upload order) with its auto-assigned seat (same order). + // Pairs each validated passenger row (upload order) with its auto-assigned seat(s). A free + // child (freeChildFlags[i]) consumes no seat at all — it's skipped when walking the hold's + // seat list, so seat N goes to the Nth non-free passenger, not the Nth row. const seatAssignments = useMemo(() => { if (!hold) return []; - return passengerRows.map((row, i) => ({ - row, - seat: hold.passengers[i]?.seat ?? null, - })); - }, [hold, passengerRows]); + let seatIdx = 0; + return passengerRows.map((row, i) => { + const isFree = freeChildFlags[i]; + const seat = isFree ? null : (hold.passengers[seatIdx++]?.seat ?? null); + return { + row, + seat, + returnSeat: returnHold?.passengers[i]?.seat ?? null, + isFree, + }; + }); + }, [hold, returnHold, passengerRows, freeChildFlags]); // ── Step 5: create booking ─────────────────────────────────────────────── const [bookingError, setBookingError] = useState(null); @@ -417,15 +505,29 @@ function GroupBookingPageContent() { if (!selectedSchedule || !selectedClass || !hold || !seatClassId) { throw new Error('Missing schedule, class, or hold — go back and try again.'); } + if (tripType === 'ROUND_TRIP' && (!selectedReturnSchedule || !selectedReturnClass || !returnHold || !returnSeatClassId)) { + throw new Error('Missing return schedule, class, or hold — go back and try again.'); + } return groupBookingApi.createGroupBooking({ scheduleId: selectedSchedule.scheduleId, holdId: hold.holdId, originStationId: selectedSchedule.origin.id, destinationStationId: selectedSchedule.destination.id, seatClassId, - bookingType: 'ONE_WAY', - passengers: seatAssignments.map(({ row, seat }) => ({ - seatId: seat!.id, + bookingType: tripType, + ...(tripType === 'ROUND_TRIP' ? { + returnScheduleId: selectedReturnSchedule!.scheduleId, + returnHoldId: returnHold!.holdId, + returnOriginStationId: selectedReturnSchedule!.origin.id, + returnDestinationStationId: selectedReturnSchedule!.destination.id, + returnSeatClassId: returnSeatClassId!, + } : {}), + passengers: seatAssignments.map(({ row, seat, returnSeat, isFree }) => ({ + // Free children (ONE_WAY only) have no seat at all — omit seatId entirely, + // matching the portal's own convention (guest-booking.service.ts treats a missing + // seatId as "unseated = free (0)"). + ...(isFree ? {} : { seatId: seat!.id }), + ...(tripType === 'ROUND_TRIP' ? { returnSeatId: returnSeat!.id } : {}), passengerName: row.fullName, dateOfBirth: row.dateOfBirth, idDocumentType: row.idDocumentType as any, @@ -444,8 +546,9 @@ function GroupBookingPageContent() { setStep('success'); }, onError: (err: any) => { - // The hold was released server-side on failure — a retry needs a fresh one. + // Both holds were released server-side on failure — a retry needs fresh ones. setHold(null); + setReturnHold(null); setBookingError(err?.response?.data?.message ?? err?.message ?? 'Could not create the booking.'); }, }); @@ -456,12 +559,16 @@ function GroupBookingPageContent() { }; // ── Step 6: pay ─────────────────────────────────────────────────────────── - const { data: paymentMethods = [] } = useQuery({ + const { data: paymentMethodsData } = useQuery({ queryKey: ['payment-methods'], queryFn: () => groupBookingApi.getPaymentMethods(), enabled: step === 'success', staleTime: 5 * 60 * 1000, }); + // Defensive: never let a malformed/unexpected response shape (e.g. an unwrap mismatch, or a + // proxy/error page returned in place of JSON) crash the page with a raw TypeError — an empty + // list here just shows "Loading payment options…" a beat longer instead. + const paymentMethods = Array.isArray(paymentMethodsData) ? paymentMethodsData : []; const enabledPaymentMethods = paymentMethods.filter((m) => m.enabled); const [selectedPaymentType, setSelectedPaymentType] = useState(null); @@ -506,8 +613,12 @@ function GroupBookingPageContent() { setStep('search'); setSelectedSchedule(null); setSelectedClass(null); + setResultsPhase('outbound'); + setSelectedReturnSchedule(null); + setSelectedReturnClass(null); clearUpload(); setHold(null); + setReturnHold(null); setAssignError(null); setBooking(null); setBookingError(null); @@ -532,16 +643,31 @@ function GroupBookingPageContent() { {/* Selection summary bar — visible from Step 2 onward */} {selectedSchedule && selectedClass && step !== 'search' && step !== 'results' && (
-
-
- +
+
+
+ +
+ {tripType === 'ROUND_TRIP' ? 'Outbound' : 'Trip'} + {selectedSchedule.trainNumber} + {selectedSchedule.origin.name} → {selectedSchedule.destination.name} + · {formatDateTime(selectedSchedule.departureAt)} + · {selectedClass.category} + · {fareTier === 'LOCAL' ? 'Local' : 'International'} rates + · {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}
- {selectedSchedule.trainNumber} - {selectedSchedule.origin.name} → {selectedSchedule.destination.name} - · {formatDateTime(selectedSchedule.departureAt)} - · {selectedClass.category} - · {fareTier === 'LOCAL' ? 'Local' : 'International'} rates - · {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''} + {tripType === 'ROUND_TRIP' && selectedReturnSchedule && selectedReturnClass && ( +
+
+ +
+ Return + {selectedReturnSchedule.trainNumber} + {selectedReturnSchedule.origin.name} → {selectedReturnSchedule.destination.name} + · {formatDateTime(selectedReturnSchedule.departureAt)} + · {selectedReturnClass.category} +
+ )}
+
+ {(['ONE_WAY', 'ROUND_TRIP'] as const).map((tt) => ( + + ))} +
+
@@ -610,6 +754,21 @@ function GroupBookingPageContent() {
+ {tripType === 'ROUND_TRIP' && ( +
+
+ + +
+
+ )} +
@@ -648,7 +807,9 @@ function GroupBookingPageContent() { ? 'Enter at least one adult or child.' : originStationId && originStationId === destinationStationId ? 'Origin and destination must be different.' - : 'Fill in origin, destination, and travel date.'} + : tripType === 'ROUND_TRIP' && travelDate && !returnDate + ? 'Pick a return date.' + : 'Fill in origin, destination, and travel date.'}

)} @@ -661,10 +822,35 @@ function GroupBookingPageContent() { {/* ── Step 2: Results ────────────────────────────────────────────── */} {step === 'results' && (
- + {tripType === 'ROUND_TRIP' && ( +

+ {resultsPhase === 'outbound' ? 'Step 2a — Choose the outbound trip' : 'Step 2b — Choose the return trip'} +

+ )} + + {tripType === 'ROUND_TRIP' && resultsPhase === 'return' && selectedSchedule && selectedClass && ( +
+
+ + {selectedSchedule.trainNumber} + {selectedSchedule.origin.name} → {selectedSchedule.destination.name} + · {formatDateTime(selectedSchedule.departureAt)} + · {selectedClass.category} +
+ +
+ )} + {searchMutation.isPending && (
{Array.from({ length: 2 }).map((_, i) => ( @@ -696,28 +882,59 @@ function GroupBookingPageContent() {
)} - {searchMutation.isSuccess && searchMutation.data.outbound.length === 0 && ( -
- -

No schedules found for this search.

-

{emptySearchMessage(searchMutation.data.outboundReason)}

-
- )} + {searchMutation.isSuccess && resultsPhase === 'outbound' && ( + <> + {searchMutation.data.outbound.length === 0 && ( +
+ +

No schedules found for this search.

+

{emptySearchMessage(searchMutation.data.outboundReason)}

+
+ )} - {searchMutation.isSuccess && (searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && ( -
-

- Nearby schedules for the same route -

- {searchMutation.data!.alternativeOutbound!.map((schedule) => ( - + {(searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && ( +
+

+ Nearby schedules for the same route +

+ {searchMutation.data.alternativeOutbound!.map((schedule) => ( + + ))} +
+ )} + + {searchMutation.data.outbound.map((schedule) => ( + ))} -
+ )} - {(searchMutation.data?.outbound ?? []).map((schedule) => ( - - ))} + {searchMutation.isSuccess && tripType === 'ROUND_TRIP' && resultsPhase === 'return' && ( + <> + {(searchMutation.data.inbound?.length ?? 0) === 0 && ( +
+ +

No return schedules found for this search.

+

{emptySearchMessage(searchMutation.data.inboundReason)}

+
+ )} + + {(searchMutation.data.alternativeInbound?.length ?? 0) > 0 && ( +
+

+ Nearby return schedules for the same route +

+ {searchMutation.data.alternativeInbound!.map((schedule) => ( + + ))} +
+ )} + + {(searchMutation.data.inbound ?? []).map((schedule) => ( + + ))} + + )}
)} @@ -735,10 +952,20 @@ function GroupBookingPageContent() {

Passenger Information

-

+

Download the template, fill in one row per passenger, then upload the completed file. Need exactly {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} ({adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? `, ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}).

+ {tripType === 'ONE_WAY' && childCount > 0 && ( +

+ The first {Math.min(childCount, adultCount)} child{Math.min(childCount, adultCount) === 1 ? '' : 'ren'} under 5 (by row order) travel{Math.min(childCount, adultCount) === 1 ? 's' : ''} free with no assigned seat, matching the passenger portal's policy — one free child per adult. Any additional children get a seat and pay the child fare. +

+ )} + {tripType === 'ROUND_TRIP' && childCount > 0 && ( +

+ Round trip requires a seat for every child on both legs — the free-child policy only applies to one-way bookings. +

+ )}
@@ -810,12 +1037,19 @@ function GroupBookingPageContent() { - {passengerRows.map((row) => ( + {passengerRows.map((row, i) => ( 0 ? 'bg-red-50/60 dark:bg-red-950/20' : undefined}> {row.rowNumber} {row.fullName || '—'} {row.dateOfBirth || '—'} - {row.passengerType || '—'} + + {row.passengerType || '—'} + {freeChildFlags[i] && ( + + Free · no seat + + )} + {row.idDocumentType || '—'} {row.nationality || '—'} @@ -876,20 +1110,53 @@ function GroupBookingPageContent() {

Read-only — seats are assigned by the system, not selected manually.

- {seatAssignments.map(({ row, seat }) => ( + {seatAssignments.map(({ row, seat, isFree }) => (
-
- {seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'} +
+ {isFree ? 'Free' : seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'}

{row.fullName}

-

{row.passengerType} · {seat?.coach ?? '—'}

+

{row.passengerType} · {isFree ? 'no seat' : (seat?.coach ?? '—')}

))}
+ {tripType === 'ROUND_TRIP' && returnHold && ( +
+
+
+
+ +
+

+ Return Seats Assigned Automatically +

+
+ Held for {Math.floor(returnHold.ttlSeconds / 60)}m {returnHold.ttlSeconds % 60}s +
+

Read-only — seats are assigned by the system, not selected manually.

+
+ {seatAssignments.map(({ row, returnSeat }) => ( +
+
+ {returnSeat ? (returnSeat.seatNumber ?? returnSeat.label ?? '?') : '—'} +
+
+

{row.fullName}

+

{row.passengerType} · {returnSeat?.coach ?? '—'}

+
+
+ ))} +
+
+ )} + {bookingError && (
{bookingError} @@ -899,7 +1166,16 @@ function GroupBookingPageContent() {
- {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} · {selectedClass.category} · {formatCurrency(selectedClass.fareMinor * totalPassengers, selectedClass.displayCurrency)} estimated total + {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} + {tripType === 'ONE_WAY' && freeChildrenCount > 0 && <> ({freeChildrenCount} free)} · {selectedClass.category} + {tripType === 'ROUND_TRIP' && selectedReturnClass && <> / {selectedReturnClass.category}} ·{' '} + + {formatCurrency( + selectedClass.fareMinor * (tripType === 'ONE_WAY' ? adultCount + paidChildrenCount : totalPassengers) + + (selectedReturnClass?.fareMinor ?? 0) * totalPassengers, + selectedClass.displayCurrency, + )} + estimated total

Coach / Class

-

{selectedClass?.category ?? '—'}

+

+ {selectedClass?.category ?? '—'} + {tripType === 'ROUND_TRIP' && selectedReturnClass && <> / {selectedReturnClass.category}} +

Adults / Children

@@ -955,7 +1234,7 @@ function GroupBookingPageContent() {

Total Passengers

-

{booking.seats.length}

+

{totalPassengers}

Total Fare

@@ -964,12 +1243,38 @@ function GroupBookingPageContent() {
+ {tripType === 'ROUND_TRIP' && selectedReturnSchedule && ( +
+

Return Trip

+
+
+

Train

+

{selectedReturnSchedule.trainNumber} · {selectedReturnSchedule.trainName}

+
+
+

Origin → Destination

+

{selectedReturnSchedule.origin.name} → {selectedReturnSchedule.destination.name}

+
+
+

Departure → Arrival

+

{formatDateTime(selectedReturnSchedule.departureAt)} → {formatDateTime(selectedReturnSchedule.arrivalAt)}

+
+
+

Class

+

{selectedReturnClass?.category ?? '—'}

+
+
+
+ )} +
-

Passenger List

+

+ {tripType === 'ROUND_TRIP' ? 'Outbound Passenger List' : 'Passenger List'} +

- {booking.seats.map((s, i) => ( + {booking.seats.filter((s) => s.leg !== 2).map((s, i) => (
{s.seat?.seatNumber ?? '?'} @@ -982,9 +1287,45 @@ function GroupBookingPageContent() {
))} + {/* Free children have no BookingSeat row at all — list them separately so they + don't silently disappear from the confirmation. */} + {seatAssignments.filter((a) => a.isFree).map(({ row }) => ( +
+
+ Free +
+
+

{row.fullName}

+

CHILD · no seat (free)

+
+
+ ))}
+ {tripType === 'ROUND_TRIP' && ( +
+
+

Return Passenger List

+
+
+ {booking.seats.filter((s) => s.leg === 2).map((s, i) => ( +
+
+ {s.seat?.seatNumber ?? '?'} +
+
+

{s.passengerName}

+

+ {s.passengerCategory} · {seatTypeLabel(s.seat?.bedPosition)} · Seat {s.seat?.seatNumber ?? '—'} · Coach {s.seat?.coach?.number} +

+
+
+ ))} +
+
+ )} + {/* ── Pay now ─────────────────────────────────────────────────── */} {!paymentResult && (
diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index 899e8cd11..b96d095a1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -9,6 +9,7 @@ import { TicketCheck, Users, TrendingUp, ShieldCheck, MailCheck, } from 'lucide-react'; import { iamAuthApi } from '@/lib/api/auth'; +import { getErrorMessage } from '@/lib/api-client'; const EDR_GREEN = 'rgb(20, 113, 76)'; @@ -52,11 +53,11 @@ export default function LoginPage() { await login(identifier.trim(), password); router.push('/dashboard'); } catch (err: any) { - const msg = err.message || err.response?.data?.message || ''; - if (msg === 'ACCESS_DENIED') { + const rawMessage = err.response?.data?.message; + if (rawMessage === 'ACCESS_DENIED') { setError('This account does not have back-office access. Contact your administrator.'); } else { - setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.'); + setError(getErrorMessage(err, 'Invalid credentials. Please try again.')); } } finally { setLoading(false); @@ -71,11 +72,11 @@ export default function LoginPage() { await iamAuthApi.forgotPassword(forgotIdentifier.trim()); setForgotSent(true); } catch (err: any) { - const msg = err.response?.data?.message || err.message || ''; + const rawMessage = err.response?.data?.message; setForgotError( - msg === 'user_not_found' + rawMessage === 'user_not_found' ? 'No account found with that email or phone number.' - : msg || 'Failed to send the reset link. Please try again.' + : getErrorMessage(err, 'Failed to send the reset link. Please try again.') ); } finally { setForgotLoading(false); diff --git a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx index 88301428d..183611374 100644 --- a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx @@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal'; import Pagination from '@/components/ui/Pagination'; import { packagesApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { getErrorMessage } from '@/lib/api-client'; const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
@@ -116,7 +117,7 @@ export default function PackageBookingsPage() {
{error && (
- Error: {(error as any)?.response?.data?.message || (error as any)?.message || String(error)} + Error: {getErrorMessage(error)}
)}
diff --git a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx index b08be0234..e0adb926c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx @@ -2,15 +2,21 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Edit, CheckCircle, Eye, Layers, Trash2 } from 'lucide-react'; +import { Plus, Edit, CheckCircle, Eye, Layers, Trash2, ImagePlus, ImageOff, X } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Modal from '@/components/ui/Modal'; import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api'; +import { getErrorMessage } from '@/lib/api-client'; import { formatDateTime, formatCurrency } from '@/lib/utils'; +// 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. +const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; + const toLocal = (iso?: string) => { if (!iso) return ''; const d = new Date(iso); @@ -48,6 +54,15 @@ export default function PackagesPage() { const [deletePackageConfirm, setDeletePackageConfirm] = useState(null); const [deletePackageError, setDeletePackageError] = useState(null); const [deletePackageCascade, setDeletePackageCascade] = useState(false); + // Image upload: `imageFile`/`imagePreviewUrl` track a newly-selected-but-not-yet-uploaded file + // (local object URL preview); `existingImageUrl` is the package's current server-side image + // when editing, shown until/unless the admin picks a replacement. + const [imageFile, setImageFile] = useState(null); + const [imagePreviewUrl, setImagePreviewUrl] = useState(null); + const [existingImageUrl, setExistingImageUrl] = useState(null); + const [imageError, setImageError] = useState(null); + const [imageUploadError, setImageUploadError] = useState(null); + const [removeImageConfirm, setRemoveImageConfirm] = useState(null); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -141,6 +156,23 @@ export default function PackagesPage() { onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to delete tier'), }); + const uploadImageMutation = useMutation({ + mutationFn: ({ id, file }: { id: string; file: File }) => packagesApi.uploadImage(id, file), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setImageUploadError(null); }, + onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to upload package image')), + }); + + const removeImageMutation = useMutation({ + mutationFn: (id: string) => packagesApi.removeImage(id), + onSuccess: (updated: any) => { + queryClient.invalidateQueries({ queryKey: ['packages'] }); + setRemoveImageConfirm(null); + setExistingImageUrl(updated?.imageUrl ?? null); + setViewPackage((prev: any) => (prev && prev.id === updated?.id ? { ...prev, imageUrl: null } : prev)); + }, + onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to remove package image')), + }); + const openEditTier = (tier: any) => { setEditingTier(tier); setTierForm({ seatClassId: tier.seatClassId ?? '', seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) }); @@ -163,13 +195,29 @@ export default function PackagesPage() { } }; + // Deliberately does not touch imageUploadError — that's shown in a page-level banner (outside + // this modal) precisely because it can still be set after the modal has already auto-closed + // (see handleSubmit), and clearing it here would wipe it out before the user ever sees it. + const resetImageSelection = () => { + setImageFile(null); + if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); + setImagePreviewUrl(null); + setImageError(null); + }; + const openCreate = () => { setForm(emptyForm); setEditingId(null); + resetImageSelection(); + setImageUploadError(null); + setExistingImageUrl(null); setModalMode('create'); }; const openEdit = (pkg: any) => { + resetImageSelection(); + setImageUploadError(null); + setExistingImageUrl(pkg.imageUrl ?? null); setForm({ code: pkg.code ?? '', name: pkg.name ?? '', @@ -216,11 +264,23 @@ export default function PackagesPage() { validUntil: form.validUntil, priceTiers: [], }; + // The image is uploaded as a separate follow-up call (the DTO here carries no image field — + // see packages.service.ts's uploadImage) so it must run after the package itself exists. + let targetId = editingId; if (modalMode === 'edit' && editingId) { await updateMutation.mutateAsync({ id: editingId, data: payload }); } else { - await createMutation.mutateAsync(payload); + const created = await createMutation.mutateAsync(payload); + targetId = created?.id ?? null; } + if (imageFile && targetId) { + try { + await uploadImageMutation.mutateAsync({ id: targetId, file: imageFile }); + } catch { + // surfaced via imageUploadError banner — the package itself was already saved successfully + } + } + resetImageSelection(); }; const field = (key: keyof typeof form) => ({ @@ -229,6 +289,24 @@ export default function PackagesPage() { setForm((f) => ({ ...f, [key]: e.target.value })), }); + const handleImageFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; // allow re-selecting the same file after a validation error + if (!file) return; + if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { + setImageError('Image must be JPEG, PNG, WEBP, or GIF.'); + return; + } + if (file.size > MAX_IMAGE_BYTES) { + setImageError(`Image must be ${Math.round(MAX_IMAGE_BYTES / (1024 * 1024))}MB or smaller.`); + return; + } + setImageError(null); + if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); + setImageFile(file); + setImagePreviewUrl(URL.createObjectURL(file)); + }; + const scheduleLabel = (s: any) => { const from = s.originStation?.name ?? s.originStationId ?? '?'; const to = s.destinationStation?.name ?? s.destinationStationId ?? '?'; @@ -237,7 +315,19 @@ export default function PackagesPage() { }; const columns = [ - { key: 'code', label: 'Package', + { + key: 'image', label: '', + render: (p: any) => ( + p.imageUrl ? ( + + ) : ( +
+ +
+ ) + ), + }, + { key: 'code', label: 'Package', render: (pkg: any) => (
{pkg.code}
@@ -299,7 +389,7 @@ export default function PackagesPage() { }, ]; - const isPending = createMutation.isPending || updateMutation.isPending; + const isPending = createMutation.isPending || updateMutation.isPending || uploadImageMutation.isPending; const allItems: any[] = data?.items || []; const filteredItems = allItems.filter((p) => { @@ -320,6 +410,18 @@ export default function PackagesPage() { New Package
+ {/* The package itself may already be saved and this modal closed by the time an image + upload/removal fails (see handleSubmit) — surfaced here rather than inside the modal + so it's never silently lost. */} + {imageUploadError && ( +
+ {imageUploadError} + +
+ )} +
@@ -366,6 +468,14 @@ export default function PackagesPage() { setViewPackage(null)} title="Package Details" size="lg"> {viewPackage && (
+ {viewPackage.imageUrl ? ( + {viewPackage.name} + ) : ( +
+ + No image +
+ )}
Code

{viewPackage.code}

Status

{viewPackage.status}

@@ -549,10 +659,23 @@ export default function PackagesPage() { error={tierError ?? undefined} /> + {/* Remove Image Confirmation */} + setRemoveImageConfirm(null)} + onConfirm={() => removeImageMutation.mutate(removeImageConfirm.id)} + title="Remove Package Image" + message={`Remove the image for "${removeImageConfirm?.name}"? The package itself will not be deleted.`} + confirmText="Remove Image" + isDanger + isLoading={removeImageMutation.isPending} + error={imageUploadError ?? undefined} + /> + {/* Create / Edit Modal */} setModalMode(null)} + onClose={() => { setModalMode(null); resetImageSelection(); }} title={modalMode === 'edit' ? 'Edit Package' : 'New Package'} size="lg" > @@ -571,6 +694,40 @@ export default function PackagesPage() {