mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Added discrepancy management for duplicate seats
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsDateString, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class GetDuplicateSeatsQuery {
|
||||
@ApiProperty({ example: '2026-07-17', description: 'Schedule date (YYYY-MM-DD)' })
|
||||
@IsDateString()
|
||||
date: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter to a specific schedule ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
scheduleId?: string;
|
||||
}
|
||||
|
||||
export class ResolveDuplicatesDto {
|
||||
@ApiProperty({
|
||||
description: 'BookingSeat IDs of the duplicate bookings to reassign',
|
||||
type: [String],
|
||||
example: ['uuid-booking-seat-1', 'uuid-booking-seat-2'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
bookingSeatIds: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Coach IDs to source replacement seats from (searched in order; first available seat per coach is used)',
|
||||
type: [String],
|
||||
example: ['uuid-coach-1', 'uuid-coach-2'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
coachIds: string[];
|
||||
}
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiBody,
|
||||
} from "@nestjs/swagger";
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
|
||||
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
@@ -306,4 +308,90 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
) {
|
||||
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
|
||||
}
|
||||
|
||||
// ── Duplicate seat management (backoffice) ────────────────────────────────
|
||||
|
||||
@Get("duplicates")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: "List duplicate seat assignments by schedule date",
|
||||
description:
|
||||
"Returns all schedules on the given date that have bookings sharing " +
|
||||
"the same seat, grouped by coach. Each coach entry includes the duplicate " +
|
||||
"groups (with full booking info) and the list of currently available seats " +
|
||||
"that can be used for reassignment.",
|
||||
})
|
||||
@ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" })
|
||||
@ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Duplicate seat report grouped by schedule → coach",
|
||||
schema: {
|
||||
example: {
|
||||
date: "2026-07-17",
|
||||
totalDuplicates: 1,
|
||||
schedules: [{
|
||||
scheduleId: "uuid",
|
||||
departureAt: "2026-07-17T06:00:00.000Z",
|
||||
origin: "Addis Ababa",
|
||||
destination: "Dire Dawa",
|
||||
coaches: [{
|
||||
coachId: "uuid",
|
||||
coachNumber: "C1",
|
||||
coachTypeName: "SBC",
|
||||
duplicates: [{
|
||||
seatId: "uuid",
|
||||
seatNumber: "12A",
|
||||
leg: 1,
|
||||
bookings: [
|
||||
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" },
|
||||
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" },
|
||||
],
|
||||
}],
|
||||
availableSeats: [
|
||||
{ seatId: "uuid", seatNumber: "14B" },
|
||||
{ seatId: "uuid", seatNumber: "15A" },
|
||||
],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) {
|
||||
return this.service.getDuplicateSeats(query.date, query.scheduleId);
|
||||
}
|
||||
|
||||
@Post("duplicates/resolve")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: "Auto-assign duplicate bookings to seats in selected coaches",
|
||||
description:
|
||||
"Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " +
|
||||
"The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " +
|
||||
"for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " +
|
||||
"change immediately, then sends an SMS notification to the passenger. " +
|
||||
"Coaches are searched in the order provided; seats within each coach are assigned by row then column.",
|
||||
})
|
||||
@ApiBody({ type: ResolveDuplicatesDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Resolution summary — resolved count, unresolved count, per-booking results",
|
||||
schema: {
|
||||
example: {
|
||||
resolved: 2,
|
||||
unresolved: 0,
|
||||
results: [
|
||||
{ bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" },
|
||||
{ bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" })
|
||||
@ApiResponse({ status: 404, description: "BookingSeat ID not found" })
|
||||
resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) {
|
||||
return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ import { SeatsService } from './seats.service';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule],
|
||||
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule, NotificationsModule],
|
||||
controllers: [SeatsController],
|
||||
providers: [SeatsService],
|
||||
exports: [SeatsService],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
|
||||
|
||||
@@ -17,6 +18,7 @@ export class SeatsService {
|
||||
private segmentsService: SegmentsService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private auditService: AuditService,
|
||||
private sms: SmsClientService,
|
||||
) {}
|
||||
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
|
||||
@@ -960,4 +962,416 @@ export class SeatsService {
|
||||
skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Duplicate-seat management (backoffice)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async getDuplicateSeats(date: string, scheduleId?: string) {
|
||||
const dayStart = new Date(`${date}T00:00:00.000Z`);
|
||||
const dayEnd = new Date(`${date}T23:59:59.999Z`);
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
departureAt: { gte: dayStart, lte: dayEnd },
|
||||
...(scheduleId ? { id: scheduleId } : {}),
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
coachAssignments: {
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
coachType: { select: { name: true } },
|
||||
seats: {
|
||||
orderBy: [{ row: 'asc' }, { col: 'asc' }],
|
||||
select: { id: true, seatNumber: true, status: true, coachId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
// All confirmed BookingSeat rows for this schedule
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduleId: schedule.id },
|
||||
{ scheduleId: null, booking: { scheduleId: schedule.id } },
|
||||
],
|
||||
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
},
|
||||
select: {
|
||||
id: true, seatId: true, scheduleId: true, leg: true, passengerName: true,
|
||||
seat: { select: { coachId: true } },
|
||||
booking: {
|
||||
select: {
|
||||
id: true, bookingRef: true, scheduleId: true,
|
||||
createdAt: true, contactPhone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Seats occupied by any confirmed journey on this schedule (source of truth)
|
||||
const journeySegments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: schedule.id,
|
||||
seatId: { not: null },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
});
|
||||
const occupiedIds = new Set(journeySegments.map(js => js.seatId!));
|
||||
|
||||
// Group BookingSeat rows by (seatId::leg) to detect duplicates
|
||||
type BS = (typeof bookingSeats)[number];
|
||||
const groups = new Map<string, BS[]>();
|
||||
for (const bs of bookingSeats) {
|
||||
const key = `${bs.seatId}::${bs.leg}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(bs);
|
||||
}
|
||||
|
||||
// All seats held by any confirmed BookingSeat — union of JourneySegment-based
|
||||
// occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments
|
||||
// are missing (e.g. created via enhanced-seats path without bookingId) are still
|
||||
// excluded from the available list.
|
||||
const bookedSeatIds = new Set<string>([
|
||||
...occupiedIds,
|
||||
...bookingSeats.map(bs => bs.seatId).filter((id): id is string => id !== null && id !== undefined),
|
||||
]);
|
||||
|
||||
const coachReports = [];
|
||||
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const coach = assignment.coach;
|
||||
|
||||
// Duplicate groups whose seat belongs to this coach
|
||||
const duplicates = [];
|
||||
for (const [key, group] of groups) {
|
||||
if (group.length <= 1) continue;
|
||||
if (group[0].seat.coachId !== coach.id) continue;
|
||||
const [seatId] = key.split('::');
|
||||
const seat = coach.seats.find(s => s.id === seatId);
|
||||
duplicates.push({
|
||||
seatId,
|
||||
seatNumber: seat?.seatNumber ?? seatId,
|
||||
leg: group[0].leg,
|
||||
bookings: group.map(bs => ({
|
||||
bookingSeatId: bs.id,
|
||||
bookingId: bs.booking.id,
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
passengerName: bs.passengerName,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
createdAt: bs.booking.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Free seats in this coach — excludes BLOCKED, all confirmed BookingSeat
|
||||
// assignments, and all confirmed JourneySegment occupancies.
|
||||
const availableSeats = coach.seats
|
||||
.filter(s =>
|
||||
(s.status as string) !== 'BLOCKED' &&
|
||||
!s.seatNumber.startsWith('-') &&
|
||||
!bookedSeatIds.has(s.id),
|
||||
)
|
||||
.map(s => ({ seatId: s.id, seatNumber: s.seatNumber }));
|
||||
|
||||
coachReports.push({
|
||||
coachId: coach.id,
|
||||
coachNumber: coach.number,
|
||||
coachTypeName: coach.coachType.name,
|
||||
duplicates,
|
||||
availableSeats,
|
||||
});
|
||||
}
|
||||
|
||||
if (coachReports.some(c => c.duplicates.length > 0)) {
|
||||
result.push({
|
||||
scheduleId: schedule.id,
|
||||
departureAt: schedule.departureAt,
|
||||
origin: schedule.originStation.name,
|
||||
destination: schedule.destinationStation.name,
|
||||
coaches: coachReports,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalDuplicates = result.reduce(
|
||||
(sum, s) => sum + s.coaches.reduce((cs, c) => cs + c.duplicates.length, 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return { date, schedules: result, totalDuplicates };
|
||||
}
|
||||
|
||||
async resolveDuplicateSeats(bookingSeatIds: string[], coachIds: string[]) {
|
||||
if (bookingSeatIds.length === 0) return { resolved: 0, unresolved: 0, results: [] };
|
||||
|
||||
// Load BookingSeat rows with full booking + schedule context
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: { id: { in: bookingSeatIds } },
|
||||
select: {
|
||||
id: true, seatId: true, leg: true, scheduleId: true,
|
||||
seat: { select: { seatNumber: true } },
|
||||
booking: {
|
||||
select: {
|
||||
id: true, bookingRef: true, scheduleId: true,
|
||||
status: true, contactPhone: true, passengerId: true,
|
||||
totalMinor: true, currency: true,
|
||||
originStationId: true, destinationStationId: true,
|
||||
schedule: {
|
||||
select: {
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
departureAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (bookingSeats.length !== bookingSeatIds.length) {
|
||||
const found = new Set(bookingSeats.map(bs => bs.id));
|
||||
const missing = bookingSeatIds.filter(id => !found.has(id));
|
||||
throw new NotFoundException(`BookingSeat(s) not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const invalid = bookingSeats.filter(bs => !['CONFIRMED', 'BOARDED'].includes(bs.booking.status));
|
||||
if (invalid.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Bookings must be CONFIRMED or BOARDED: ${invalid.map(bs => bs.booking.bookingRef).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Load all non-blocked, non-removed seats from the selected coaches (ordered for deterministic pick)
|
||||
const coachSeats = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
coachId: { in: coachIds },
|
||||
status: { not: 'BLOCKED' },
|
||||
NOT: { seatNumber: { startsWith: '-' } },
|
||||
},
|
||||
select: { id: true, seatNumber: true, coachId: true, row: true, col: true },
|
||||
orderBy: [{ coachId: 'asc' }, { row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
|
||||
// Build occupied-seat sets per schedule from confirmed JourneySegments
|
||||
const scheduleIds = [
|
||||
...new Set(
|
||||
bookingSeats
|
||||
.map(bs => bs.scheduleId ?? bs.booking.scheduleId)
|
||||
.filter((id): id is string => id !== null && id !== undefined),
|
||||
),
|
||||
];
|
||||
|
||||
const occupiedBySchedule = new Map<string, Set<string>>();
|
||||
await Promise.all(
|
||||
scheduleIds.map(async scheduleId => {
|
||||
const segments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId: { not: null },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT', 'BOARDED'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
});
|
||||
occupiedBySchedule.set(scheduleId, new Set(segments.map(s => s.seatId!)));
|
||||
}),
|
||||
);
|
||||
|
||||
// Track seats assigned within this batch to prevent double-assignment
|
||||
const assignedInBatch = new Set<string>();
|
||||
|
||||
const results: { bookingRef: string; oldSeatNumber: string; newSeatNumber: string; contactPhone: string | null }[] = [];
|
||||
const unresolved: { bookingRef: string; reason: string }[] = [];
|
||||
|
||||
for (const bs of bookingSeats) {
|
||||
const scheduleId = (bs.scheduleId ?? bs.booking.scheduleId)!;
|
||||
const occupied = occupiedBySchedule.get(scheduleId) ?? new Set<string>();
|
||||
|
||||
// Pick the first available seat across the selected coaches
|
||||
const newSeat = coachSeats.find(
|
||||
seat =>
|
||||
!occupied.has(seat.id) &&
|
||||
!assignedInBatch.has(seat.id) &&
|
||||
seat.id !== bs.seatId,
|
||||
);
|
||||
|
||||
if (!newSeat) {
|
||||
unresolved.push({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
reason: 'No available seat found in selected coaches',
|
||||
});
|
||||
this.logger.warn(
|
||||
`Duplicate resolve: no seat available for ${bs.booking.bookingRef} (schedule ${scheduleId})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
// 1. Change the seat on the booking and ticket.
|
||||
await tx.bookingSeat.update({
|
||||
where: { id: bs.id },
|
||||
data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber },
|
||||
});
|
||||
await tx.ticket.updateMany({
|
||||
where: { bookingId: bs.booking.id, seatId: bs.seatId, leg: bs.leg },
|
||||
data: { seatId: newSeat.id },
|
||||
});
|
||||
|
||||
// 2. Point the existing JourneySegments to the new seat.
|
||||
// The Journey is already linked to this booking via bookingId;
|
||||
// just update the seatId in its hop rows for this schedule.
|
||||
const journey = await tx.journey.findFirst({
|
||||
where: { bookingId: bs.booking.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!journey) {
|
||||
// No Journey/JourneySegment for this booking (e.g. duplicate that was never
|
||||
// processed by finalizePaymentSuccess). Create them now using the same logic,
|
||||
// scoped to the booking's origin→destination leg so the seatmap shows BOOKED
|
||||
// only for the correct range of stops.
|
||||
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
|
||||
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
|
||||
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { stationId: true },
|
||||
});
|
||||
|
||||
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
|
||||
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
|
||||
const fromIdx = originIdx >= 0 ? originIdx : 0;
|
||||
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
|
||||
|
||||
const newJourney = await tx.journey.create({
|
||||
data: {
|
||||
passengerId: bs.booking.passengerId,
|
||||
bookingId: bs.booking.id,
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: bs.booking.totalMinor,
|
||||
currency: bs.booking.currency,
|
||||
} as any,
|
||||
});
|
||||
|
||||
const segments = [];
|
||||
for (let i = fromIdx; i < toIdx; i++) {
|
||||
segments.push({
|
||||
journeyId: newJourney.id,
|
||||
scheduleId,
|
||||
segmentOrder: i - fromIdx,
|
||||
seatId: newSeat.id,
|
||||
coachId: newSeat.coachId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
|
||||
}
|
||||
this.logger.log(
|
||||
`No Journey for ${bs.booking.bookingRef} — created Journey + ${segments.length} segment(s) for seat ${newSeat.seatNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { count } = await tx.journeySegment.updateMany({
|
||||
where: { journeyId: journey.id, scheduleId, seatId: bs.seatId },
|
||||
data: { seatId: newSeat.id },
|
||||
});
|
||||
|
||||
// Journey exists but had no segments (e.g. booking confirmed via a path
|
||||
// that skipped JourneySegment creation). Create them now for the new seat
|
||||
// so the seatmap reflects BOOKED.
|
||||
if (count === 0) {
|
||||
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
|
||||
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { stationId: true },
|
||||
});
|
||||
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
|
||||
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
|
||||
const fromIdx = originIdx >= 0 ? originIdx : 0;
|
||||
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
|
||||
const segments = [];
|
||||
for (let i = fromIdx; i < toIdx; i++) {
|
||||
segments.push({
|
||||
journeyId: journey.id,
|
||||
scheduleId,
|
||||
segmentOrder: i - fromIdx,
|
||||
seatId: newSeat.id,
|
||||
coachId: newSeat.coachId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
|
||||
}
|
||||
this.logger.log(
|
||||
`Seat reassigned: ${bs.booking.bookingRef} ` +
|
||||
`${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` +
|
||||
`(0 existing segments — created ${segments.length} new hop(s))`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`Seat reassigned: ${bs.booking.bookingRef} ` +
|
||||
`${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` +
|
||||
`(${count} segment hop(s) updated)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Mark as taken so the next booking in this batch doesn't get the same seat
|
||||
assignedInBatch.add(newSeat.id);
|
||||
occupied.add(newSeat.id);
|
||||
|
||||
const oldSeatNumber = bs.seat?.seatNumber ?? '?';
|
||||
const origin = bs.booking.schedule?.originStation?.name ?? '';
|
||||
const dest = bs.booking.schedule?.destinationStation?.name ?? '';
|
||||
|
||||
if (bs.booking.contactPhone) {
|
||||
const message =
|
||||
`EDR: Your booking ${bs.booking.bookingRef} (${origin} → ${dest}): ` +
|
||||
`your seat has been changed from seat ${oldSeatNumber} to seat ${newSeat.seatNumber}. ` +
|
||||
`We apologize for any inconvenience.`;
|
||||
await this.sms.sendSms({ to: bs.booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Duplicate resolved: ${bs.booking.bookingRef} seat ${oldSeatNumber} → ${newSeat.seatNumber}`,
|
||||
);
|
||||
|
||||
results.push({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
oldSeatNumber,
|
||||
newSeatNumber: newSeat.seatNumber,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
resolved: results.length,
|
||||
unresolved: unresolved.length,
|
||||
results,
|
||||
...(unresolved.length > 0 ? { unresolvedDetails: unresolved } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function DiscrepancyLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
519
apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx
Normal file
519
apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react';
|
||||
import { seatsApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DuplicateBooking {
|
||||
bookingSeatId: string;
|
||||
bookingId: string;
|
||||
bookingRef: string;
|
||||
passengerName: string;
|
||||
contactPhone: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface DuplicateSeatGroup {
|
||||
seatId: string;
|
||||
seatNumber: string;
|
||||
leg: number;
|
||||
bookings: DuplicateBooking[];
|
||||
}
|
||||
|
||||
interface AvailableSeat {
|
||||
seatId: string;
|
||||
seatNumber: string;
|
||||
}
|
||||
|
||||
interface CoachReport {
|
||||
coachId: string;
|
||||
coachNumber: string;
|
||||
coachTypeName: string;
|
||||
duplicates: DuplicateSeatGroup[];
|
||||
availableSeats: AvailableSeat[];
|
||||
}
|
||||
|
||||
interface ScheduleReport {
|
||||
scheduleId: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
departureAt: string;
|
||||
coaches: CoachReport[];
|
||||
}
|
||||
|
||||
interface DuplicatesResponse {
|
||||
date: string;
|
||||
schedules: ScheduleReport[];
|
||||
totalDuplicates: number;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function scheduleDuplicateCount(s: ScheduleReport) {
|
||||
return s.coaches.reduce((sum, c) => sum + c.duplicates.length, 0);
|
||||
}
|
||||
|
||||
// ── Resolve modal ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ResolveModalProps {
|
||||
schedule: ScheduleReport;
|
||||
coach: CoachReport;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function ResolveModal({ schedule, coach, onClose, onSuccess }: ResolveModalProps) {
|
||||
// Default: pre-select all-but-first passenger in every duplicate group
|
||||
const defaultSelected = new Set<string>(
|
||||
coach.duplicates.flatMap(g => g.bookings.slice(1).map(b => b.bookingSeatId)),
|
||||
);
|
||||
const [selectedSeats, setSelectedSeats] = useState<Set<string>>(defaultSelected);
|
||||
|
||||
// Coaches that have at least one available seat (pre-select all)
|
||||
const coachesWithSeats = schedule.coaches.filter(c => c.availableSeats.length > 0);
|
||||
const [selectedCoachIds, setSelectedCoachIds] = useState<Set<string>>(
|
||||
new Set(coachesWithSeats.map(c => c.coachId)),
|
||||
);
|
||||
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: { bookingSeatIds: string[]; coachIds: string[] }) =>
|
||||
seatsApi.resolveDuplicates(data),
|
||||
onSuccess: (res) => {
|
||||
setSuccessMsg(
|
||||
`${res.resolved ?? 0} passenger(s) successfully reassigned.` +
|
||||
(res.unresolved > 0 ? ` ${res.unresolved} could not be resolved (no available seat).` : ''),
|
||||
);
|
||||
setErrorMsg(null);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setErrorMsg(err?.response?.data?.message ?? 'Failed to resolve duplicates.');
|
||||
},
|
||||
});
|
||||
|
||||
function toggleBookingSeat(id: string) {
|
||||
setSelectedSeats(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCoach(id: string) {
|
||||
setSelectedCoachIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAssign() {
|
||||
setErrorMsg(null);
|
||||
if (selectedSeats.size === 0) {
|
||||
setErrorMsg('Select at least one passenger to reassign.');
|
||||
return;
|
||||
}
|
||||
if (selectedCoachIds.size === 0) {
|
||||
setErrorMsg('Select at least one coach to source the replacement seat from.');
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
bookingSeatIds: [...selectedSeats],
|
||||
coachIds: [...selectedCoachIds],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl bg-white dark:bg-gray-900 shadow-2xl flex flex-col">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Resolve Duplicates — {coach.coachNumber}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{schedule.origin} → {schedule.destination} · {formatDateTime(schedule.departureAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
|
||||
|
||||
{/* Duplicate seat groups */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Duplicate seat assignments
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Check the passengers you want to reassign to a new seat. Unchecked passengers keep their current seat.
|
||||
</p>
|
||||
|
||||
{coach.duplicates.map(group => (
|
||||
<div
|
||||
key={`${group.seatId}-${group.leg}`}
|
||||
className="rounded-lg border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-950/30 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertCircle className="w-4 h-4 text-orange-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-orange-800 dark:text-orange-300">
|
||||
Seat {group.seatNumber} — {group.bookings.length} passengers assigned
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{group.bookings.map((b, idx) => {
|
||||
const checked = selectedSeats.has(b.bookingSeatId);
|
||||
return (
|
||||
<label
|
||||
key={b.bookingSeatId}
|
||||
className="flex items-start gap-3 cursor-pointer rounded-lg px-3 py-2 hover:bg-orange-100 dark:hover:bg-orange-900/30 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleBookingSeat(b.bookingSeatId)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{b.passengerName || '—'}
|
||||
</span>
|
||||
<span className="text-xs font-mono bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 px-1.5 py-0.5 rounded">
|
||||
{b.bookingRef}
|
||||
</span>
|
||||
{idx === 0 && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-1.5 py-0.5 rounded">
|
||||
earliest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{b.contactPhone ?? 'No phone'} · Booked {formatDateTime(b.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Coach selection */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Reassign to seats in
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
The system picks the first available seat in the selected coaches.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{coachesWithSeats.length === 0 ? (
|
||||
<p className="text-sm text-red-500">No coaches have available seats on this schedule.</p>
|
||||
) : (
|
||||
coachesWithSeats.map(c => (
|
||||
<label
|
||||
key={c.coachId}
|
||||
className="flex items-center gap-3 cursor-pointer rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCoachIds.has(c.coachId)}
|
||||
onChange={() => toggleCoach(c.coachId)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{c.coachNumber}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
{c.coachTypeName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
{c.availableSeats.length} available
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{errorMsg && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{errorMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 px-4 py-3">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-700 dark:text-green-400">{successMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{successMsg ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
{!successMsg && (
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={mutation.isPending || selectedSeats.size === 0 || selectedCoachIds.size === 0}
|
||||
className="flex items-center gap-2 px-5 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Assign {selectedSeats.size > 0 ? `${selectedSeats.size} passenger${selectedSeats.size > 1 ? 's' : ''}` : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Coach card ────────────────────────────────────────────────────────────
|
||||
|
||||
interface CoachCardProps {
|
||||
coach: CoachReport;
|
||||
schedule: ScheduleReport;
|
||||
onResolve: () => void;
|
||||
}
|
||||
|
||||
function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasDuplicates = coach.duplicates.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border ${hasDuplicates ? 'border-orange-200 dark:border-orange-800' : 'border-gray-200 dark:border-gray-700'} bg-white dark:bg-gray-900 overflow-hidden`}>
|
||||
{/* Card header */}
|
||||
<div className="flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{coach.coachNumber}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{coach.coachTypeName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{coach.availableSeats.length} available seats</span>
|
||||
{hasDuplicates && (
|
||||
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400 font-medium">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{coach.duplicates.length} duplicate{coach.duplicates.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasDuplicates && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
{hasDuplicates && (
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 transition-colors"
|
||||
title={expanded ? 'Collapse' : 'View passengers'}
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded passenger list */}
|
||||
{expanded && hasDuplicates && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{coach.duplicates.map(group => (
|
||||
<div key={`${group.seatId}-${group.leg}`} className="px-5 py-3">
|
||||
<p className="text-xs font-semibold text-orange-600 dark:text-orange-400 mb-2">
|
||||
Seat {group.seatNumber} — {group.bookings.length} passengers
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{group.bookings.map((b, idx) => (
|
||||
<div key={b.bookingSeatId} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-5 h-5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs flex items-center justify-center font-medium shrink-0">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-gray-900 dark:text-white">{b.passengerName || '—'}</span>
|
||||
<span className="ml-2 text-xs font-mono text-gray-500 dark:text-gray-400">{b.bookingRef}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{b.contactPhone ?? '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DiscrepancyPage() {
|
||||
const [date, setDate] = useState(today());
|
||||
const [searchDate, setSearchDate] = useState('');
|
||||
const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery<DuplicatesResponse>({
|
||||
queryKey: ['seat-duplicates', searchDate],
|
||||
queryFn: () => seatsApi.getDuplicates(searchDate),
|
||||
enabled: !!searchDate,
|
||||
});
|
||||
|
||||
function handleSearch() {
|
||||
if (date) setSearchDate(date);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-5xl mx-auto">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-950/40">
|
||||
<Layers className="w-6 h-6 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seat Discrepancy</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Detect and resolve duplicate seat assignments by schedule date
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date picker */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!date || isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{isError && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
Failed to load duplicate seat data. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary banner */}
|
||||
{data && (
|
||||
<div className={`rounded-xl border px-5 py-4 flex items-center gap-3 ${
|
||||
data.totalDuplicates > 0
|
||||
? 'bg-orange-50 dark:bg-orange-950/20 border-orange-200 dark:border-orange-800'
|
||||
: 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
|
||||
}`}>
|
||||
{data.totalDuplicates > 0 ? (
|
||||
<AlertCircle className="w-5 h-5 text-orange-500 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
||||
)}
|
||||
<span className={`text-sm font-medium ${
|
||||
data.totalDuplicates > 0
|
||||
? 'text-orange-800 dark:text-orange-300'
|
||||
: 'text-green-800 dark:text-green-300'
|
||||
}`}>
|
||||
{data.totalDuplicates > 0
|
||||
? `${data.totalDuplicates} duplicate seat assignment${data.totalDuplicates > 1 ? 's' : ''} found across ${data.schedules.length} schedule${data.schedules.length > 1 ? 's' : ''} on ${data.date}`
|
||||
: `No duplicate seat assignments found on ${data.date}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results per schedule */}
|
||||
{data?.schedules.map(schedule => (
|
||||
<div key={schedule.scheduleId} className="space-y-3">
|
||||
{/* Schedule header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{schedule.origin} → {schedule.destination}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDateTime(schedule.departureAt)} · {scheduleDuplicateCount(schedule)} duplicate{scheduleDuplicateCount(schedule) !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coach cards grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{schedule.coaches.map(coach => (
|
||||
<CoachCard
|
||||
key={coach.coachId}
|
||||
coach={coach}
|
||||
schedule={schedule}
|
||||
onResolve={() => setResolveTarget({ schedule, coach })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Empty state when searched but no results */}
|
||||
{data && data.schedules.length === 0 && data.totalDuplicates === 0 && searchDate && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-400 mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">All seats are correctly assigned for {data.date}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolve modal */}
|
||||
{resolveTarget && (
|
||||
<ResolveModal
|
||||
schedule={resolveTarget.schedule}
|
||||
coach={resolveTarget.coach}
|
||||
onClose={() => setResolveTarget(null)}
|
||||
onSuccess={() => {
|
||||
refetch();
|
||||
// Keep modal open to show success message; user closes manually
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Banknote,
|
||||
Activity,
|
||||
Smartphone,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -64,7 +65,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
|
||||
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
|
||||
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.view },
|
||||
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
|
||||
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
|
||||
{ name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -159,6 +159,10 @@ export const seatsApi = {
|
||||
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
|
||||
setMaintenance: (seatId: string, reason: string) => apiClient.post<any>(`/seats/${seatId}/maintenance`, { reason }),
|
||||
clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`),
|
||||
getDuplicates: (date: string, scheduleId?: string) =>
|
||||
apiClient.get<any>(`/seats/duplicates?date=${date}${scheduleId ? `&scheduleId=${scheduleId}` : ''}`),
|
||||
resolveDuplicates: (data: { bookingSeatIds: string[]; coachIds: string[] }) =>
|
||||
apiClient.post<any>('/seats/duplicates/resolve', data),
|
||||
};
|
||||
|
||||
// Payments API
|
||||
|
||||
Reference in New Issue
Block a user