mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Generate ticket, dashboard, excess luggage rate updates
This commit is contained in:
@@ -612,7 +612,7 @@ export class BookingsService {
|
|||||||
passenger: { select: { id: true, iamUserId: true } },
|
passenger: { select: { id: true, iamUserId: true } },
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||||
paymentIntent: true,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: { include: { coach: true } } } },
|
||||||
package: { select: { id: true, name: true, code: true } },
|
package: { select: { id: true, name: true, code: true } },
|
||||||
priceTier: { select: { id: true, label: true, priceMinor: true } },
|
priceTier: { select: { id: true, label: true, priceMinor: true } },
|
||||||
},
|
},
|
||||||
@@ -671,7 +671,7 @@ export class BookingsService {
|
|||||||
|
|
||||||
const mappedRegular = regularItems.map((booking: any) => {
|
const mappedRegular = regularItems.map((booking: any) => {
|
||||||
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
||||||
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
|
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory, seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: s.seat.coach?.number ?? null } : null }));
|
||||||
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
|
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
|
||||||
|
|
||||||
// Resolve contact: DB row → IAM → TravelerProfile notes → seat name fallback
|
// Resolve contact: DB row → IAM → TravelerProfile notes → seat name fallback
|
||||||
@@ -704,6 +704,15 @@ export class BookingsService {
|
|||||||
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
|
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
|
||||||
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||||
passengers: uniquePassengers,
|
passengers: uniquePassengers,
|
||||||
|
seats: booking.seats.map((s: any) => ({
|
||||||
|
passengerName: s.passengerName,
|
||||||
|
passengerCategory: s.passengerCategory,
|
||||||
|
leg: s.leg ?? 1,
|
||||||
|
fareMinor: s.fareMinor,
|
||||||
|
idDocumentType: s.idDocumentType,
|
||||||
|
verifaydaVerified: s.verifaydaVerified,
|
||||||
|
seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null,
|
||||||
|
})),
|
||||||
schedule: {
|
schedule: {
|
||||||
train: booking.schedule.train,
|
train: booking.schedule.train,
|
||||||
originStation: (booking as any).originStationId
|
originStation: (booking as any).originStationId
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
|
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||||
|
|
||||||
@ApiTags('Dashboard')
|
@ApiTags('Dashboard')
|
||||||
@Controller('dashboard')
|
@Controller('dashboard')
|
||||||
@UseGuards(JwtGuard)
|
|
||||||
@ApiBearerAuth('JWT-auth')
|
|
||||||
export class DashboardController {
|
export class DashboardController {
|
||||||
constructor(private service: DashboardService) {}
|
constructor(private service: DashboardService) {}
|
||||||
@Get(':passengerId') @ApiOperation({ summary: 'Get home dashboard aggregate for passenger' })
|
|
||||||
|
@Get('backoffice-stats')
|
||||||
|
@PassengerAdmin()
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
|
||||||
|
getBackofficeStats() { return this.service.getBackofficeStats(); }
|
||||||
|
|
||||||
|
@Get(':passengerId')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Get home dashboard aggregate for passenger' })
|
||||||
getHomeDashboard(@Param('passengerId') id: string) { return this.service.getHomeDashboard(id); }
|
getHomeDashboard(@Param('passengerId') id: string) { return this.service.getHomeDashboard(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,57 @@ export class DashboardService {
|
|||||||
@InjectDataSource() private dataSource: DataSource,
|
@InjectDataSource() private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async getBackofficeStats() {
|
||||||
|
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
|
||||||
|
await Promise.all([
|
||||||
|
this.prisma.booking.count(),
|
||||||
|
this.prisma.booking.count({ where: { packageId: { not: null } } }),
|
||||||
|
this.prisma.ticket.count(),
|
||||||
|
this.prisma.passenger.count(),
|
||||||
|
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
|
||||||
|
SELECT
|
||||||
|
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
|
||||||
|
SUM(COALESCE("displayTotalMinor", "totalMinor")) AS total
|
||||||
|
FROM passenger."Booking"
|
||||||
|
WHERE status IN ('CONFIRMED', 'BOARDED')
|
||||||
|
AND "packageId" IS NULL
|
||||||
|
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
|
||||||
|
GROUP BY COALESCE("displayCurrency"::text, "currency"::text)
|
||||||
|
`,
|
||||||
|
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
|
||||||
|
SELECT
|
||||||
|
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
|
||||||
|
SUM(COALESCE("displayTotalMinor", "totalMinor")) AS total
|
||||||
|
FROM passenger."Booking"
|
||||||
|
WHERE status IN ('CONFIRMED', 'BOARDED')
|
||||||
|
AND "packageId" IS NOT NULL
|
||||||
|
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
|
||||||
|
GROUP BY COALESCE("displayCurrency"::text, "currency"::text)
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalPackageTickets = await this.prisma.ticket.count({
|
||||||
|
where: { booking: { packageId: { not: null } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const toMap = (rows: { currency: string; total: bigint }[]) =>
|
||||||
|
Object.entries(
|
||||||
|
rows.reduce((m, r) => { m[r.currency] = Number(r.total); return m; }, {} as Record<string, number>),
|
||||||
|
).map(([currency, totalMinor]) => ({ currency, totalMinor }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalBookings,
|
||||||
|
totalPackageBookings,
|
||||||
|
totalNormalBookings: totalBookings - totalPackageBookings,
|
||||||
|
totalTickets,
|
||||||
|
totalPackageTickets,
|
||||||
|
totalNormalTickets: totalTickets - totalPackageTickets,
|
||||||
|
totalPassengers,
|
||||||
|
revenueByCurrency: toMap(revenueRows),
|
||||||
|
packageRevenueByCurrency: toMap(packageRevenueRows),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getHomeDashboard(passengerId: string) {
|
async getHomeDashboard(passengerId: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
|
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { IsInt, IsPositive, IsString } from 'class-validator';
|
import { IsInt, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||||
import { ExcessBaggageService } from './excess-baggage.service';
|
import { ExcessBaggageService } from './excess-baggage.service';
|
||||||
import {
|
import {
|
||||||
LogExcessBaggageDto,
|
LogExcessBaggageDto,
|
||||||
@@ -12,8 +12,8 @@ import { PassengerAdmin } from '../../common/passenger-guards';
|
|||||||
|
|
||||||
class UpsertBaggageAllowanceDto {
|
class UpsertBaggageAllowanceDto {
|
||||||
@IsString() seatClassId: string;
|
@IsString() seatClassId: string;
|
||||||
@IsInt() @IsPositive() maxWeightKg: number;
|
@IsOptional() @IsInt() maxWeightKg?: number;
|
||||||
@IsInt() @IsPositive() maxPiecesCount: number;
|
@IsOptional() @IsInt() maxPiecesCount?: number;
|
||||||
@IsInt() @IsPositive() excessFeePerKg: number;
|
@IsInt() @IsPositive() excessFeePerKg: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export class ExcessBaggageService {
|
|||||||
const booking = await this.prisma.booking.findUnique({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
where: { id: dto.bookingId },
|
where: { id: dto.bookingId },
|
||||||
include: {
|
include: {
|
||||||
seats: { take: 1, include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
|
||||||
passenger: { include: { user: true } },
|
passenger: { include: { user: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -51,20 +50,9 @@ export class ExcessBaggageService {
|
|||||||
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage');
|
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve fee per kg from BaggageAllowance via seat class
|
const allowance = await this.prisma.baggageAllowance.findFirst({ orderBy: { createdAt: 'asc' } });
|
||||||
const coachTypeId = booking.seats[0]?.seat?.coach?.coachTypeId;
|
if (!allowance) throw new BadRequestException('No excess baggage rate configured. Please set a rate in Tariff Rates.');
|
||||||
let feePerKgMinor = 5000; // 50 ETB default fallback (in minor)
|
const feePerKgMinor = allowance.excessFeePerKg;
|
||||||
if (coachTypeId) {
|
|
||||||
const seatClass = await this.prisma.seatClass.findFirst({
|
|
||||||
where: { coachTypeId },
|
|
||||||
});
|
|
||||||
if (seatClass) {
|
|
||||||
const allowance = await this.prisma.baggageAllowance.findFirst({
|
|
||||||
where: { seatClassId: seatClass.id },
|
|
||||||
});
|
|
||||||
if (allowance) feePerKgMinor = allowance.excessFeePerKg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalMinor = feePerKgMinor * dto.excessWeightKg;
|
const totalMinor = feePerKgMinor * dto.excessWeightKg;
|
||||||
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
|
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
|
||||||
@@ -289,11 +277,16 @@ export class ExcessBaggageService {
|
|||||||
return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null }));
|
return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertAllowance(dto: { seatClassId: string; maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }) {
|
async upsertAllowance(dto: { seatClassId: string; maxWeightKg?: number; maxPiecesCount?: number; excessFeePerKg: number }) {
|
||||||
return this.prisma.baggageAllowance.upsert({
|
const existing = await this.prisma.baggageAllowance.findFirst({ where: { seatClassId: dto.seatClassId } });
|
||||||
where: { seatClassId: dto.seatClassId } as any,
|
if (existing) {
|
||||||
update: { maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
|
return this.prisma.baggageAllowance.update({
|
||||||
create: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
|
where: { id: existing.id },
|
||||||
|
data: { maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.prisma.baggageAllowance.create({
|
||||||
|
data: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +295,7 @@ export class ExcessBaggageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteAllowance(id: string) {
|
async deleteAllowance(id: string) {
|
||||||
await this.prisma.baggageAllowance.delete({ where: { id } });
|
await this.prisma.baggageAllowance.deleteMany({ where: { id } });
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ import { PassengerAdmin } from '../../common/passenger-guards';
|
|||||||
export class TicketsController {
|
export class TicketsController {
|
||||||
constructor(private service: TicketsService) {}
|
constructor(private service: TicketsService) {}
|
||||||
|
|
||||||
|
@Post('smart-assign/:bookingId')
|
||||||
|
@PassengerAdmin()
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Smart seat assignment + ticket generation',
|
||||||
|
description:
|
||||||
|
'Keeps original seats if still free, auto-reassigns to an available seat of the same coach type if taken, ' +
|
||||||
|
'or throws 409 if the schedule is fully booked in that class.',
|
||||||
|
})
|
||||||
|
smartAssignAndGenerate(@Param('bookingId') bookingId: string) {
|
||||||
|
return this.service.smartAssignAndGenerate(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('generate/:bookingId')
|
@Post('generate/:bookingId')
|
||||||
@PassengerAdmin()
|
@PassengerAdmin()
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException, ConflictException, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
@@ -174,6 +174,109 @@ export class TicketsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Smart seat assignment for conflict resolution:
|
||||||
|
// 1. If the original seat is still free → keep it and generate
|
||||||
|
// 2. If the original seat is taken → find a truly available seat in the same coach type
|
||||||
|
// (excludes: confirmed/boarded bookings, active holds, seat blocks, BOOKED/HELD/REMOVED status)
|
||||||
|
// 3. If no seats of that class remain → throw so the agent is notified
|
||||||
|
async smartAssignAndGenerate(bookingId: string) {
|
||||||
|
const booking = await this.prisma.booking.findUnique({
|
||||||
|
where: { id: bookingId },
|
||||||
|
include: {
|
||||||
|
seats: {
|
||||||
|
include: {
|
||||||
|
seat: { include: { coach: { include: { coachType: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
|
||||||
|
// Seats taken by other confirmed/boarded bookings on this schedule
|
||||||
|
const takenByOthers = await this.prisma.bookingSeat.findMany({
|
||||||
|
where: {
|
||||||
|
booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||||
|
seat: { coach: { assignments: { some: { scheduleId: booking.scheduleId } } } },
|
||||||
|
},
|
||||||
|
select: { seatId: true },
|
||||||
|
}).then(rows => new Set(rows.map(r => r.seatId)));
|
||||||
|
|
||||||
|
// Seats held by any active SeatHold (not yet expired)
|
||||||
|
const heldSeatIds = await this.prisma.seatHold.findMany({
|
||||||
|
where: { expiresAt: { gt: new Date() } },
|
||||||
|
select: { seatIds: true },
|
||||||
|
}).then(rows => new Set(rows.flatMap(r => r.seatIds)));
|
||||||
|
|
||||||
|
// Seats with an active SeatBlock
|
||||||
|
const blockedSeatIds = await this.prisma.seatBlock.findMany({
|
||||||
|
select: { seatId: true },
|
||||||
|
}).then(rows => new Set(rows.map(r => r.seatId)));
|
||||||
|
|
||||||
|
// Union of all unavailable seat IDs (excluding the booking's own seats)
|
||||||
|
const ownSeatIds = new Set((booking as any).seats.map((bs: any) => bs.seatId as string));
|
||||||
|
const unavailableIds = new Set([
|
||||||
|
...[...takenByOthers].filter(id => !ownSeatIds.has(id)),
|
||||||
|
...[...heldSeatIds],
|
||||||
|
...[...blockedSeatIds],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const reassigned: { seatNumber: string; newSeatNumber: string }[] = [];
|
||||||
|
|
||||||
|
for (const bs of (booking as any).seats) {
|
||||||
|
const originalSeatId: string = bs.seatId;
|
||||||
|
|
||||||
|
// Case 1: original seat is still free — nothing to do
|
||||||
|
if (!takenByOthers.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId)) continue;
|
||||||
|
|
||||||
|
// Case 2: original seat is unavailable — find a truly available seat in the same coach type
|
||||||
|
const coachTypeId: string | undefined = bs.seat?.coach?.coachTypeId;
|
||||||
|
|
||||||
|
const candidate = await this.prisma.seat.findFirst({
|
||||||
|
where: {
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
seatNumber: { not: '' },
|
||||||
|
NOT: [
|
||||||
|
{ seatNumber: { startsWith: '-' } },
|
||||||
|
{ id: { in: [...unavailableIds] } },
|
||||||
|
],
|
||||||
|
coach: {
|
||||||
|
assignments: { some: { scheduleId: booking.scheduleId } },
|
||||||
|
...(coachTypeId ? { coachTypeId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case 3: no seats left in that class
|
||||||
|
if (!candidate) {
|
||||||
|
const className = bs.seat?.coach?.coachType?.name ?? 'the same class';
|
||||||
|
throw new ConflictException(
|
||||||
|
`No available seats remaining in ${className} on this schedule. Please contact the passenger to arrange an alternative.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.bookingSeat.update({
|
||||||
|
where: { id: bs.id },
|
||||||
|
data: { seatId: candidate.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark the newly assigned seat as taken so subsequent passengers in the
|
||||||
|
// same booking don't get assigned the same seat.
|
||||||
|
unavailableIds.add(candidate.id);
|
||||||
|
|
||||||
|
reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.auditService.log({
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'Booking',
|
||||||
|
entityId: bookingId,
|
||||||
|
newData: { smartReassigned: true, changes: reassigned },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.generate(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
async generate(bookingId: string) {
|
async generate(bookingId: string) {
|
||||||
if (!bookingId) throw new BadRequestException('Booking ID is required');
|
if (!bookingId) throw new BadRequestException('Booking ID is required');
|
||||||
|
|
||||||
@@ -231,11 +334,29 @@ export class TicketsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for seat conflicts before deleting existing tickets or issuing new ones
|
||||||
|
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
|
||||||
|
const conflictingSeats = await this.prisma.bookingSeat.findMany({
|
||||||
|
where: {
|
||||||
|
seatId: { in: seatIds },
|
||||||
|
booking: {
|
||||||
|
id: { not: bookingId },
|
||||||
|
status: { in: ['CONFIRMED', 'BOARDED'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { seat: true },
|
||||||
|
});
|
||||||
|
if (conflictingSeats.length > 0) {
|
||||||
|
const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', ');
|
||||||
|
throw new ConflictException(
|
||||||
|
`Seat(s) ${labels} are already confirmed for another booking.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||||
|
|
||||||
// Generate one ticket per unique passenger (grouped by passengerName)
|
// Generate one ticket per unique passenger (grouped by passengerName)
|
||||||
const tickets = [];
|
const tickets = [];
|
||||||
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
|
|
||||||
|
|
||||||
// Group seats by passenger
|
// Group seats by passenger
|
||||||
const passengerSeatsMap = new Map<string, any[]>();
|
const passengerSeatsMap = new Map<string, any[]>();
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ function BookingsPageContent() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const smartAssignMutation = useMutation({
|
||||||
|
mutationFn: (bookingId: string) => bookingsApi.smartAssign(bookingId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['bookings'] });
|
||||||
|
setSuccessMessage('Seats assigned and ticket generated successfully');
|
||||||
|
setTimeout(() => setSuccessMessage(''), 4000);
|
||||||
|
setGenerateTicketBooking(null);
|
||||||
|
setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' });
|
||||||
|
setGenerateTicketTouched({ paymentReference: false, paymentMethod: false });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const forceConfirmMutation = useMutation({
|
const forceConfirmMutation = useMutation({
|
||||||
mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) =>
|
mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) =>
|
||||||
bookingsApi.forceConfirm(bookingId, data),
|
bookingsApi.forceConfirm(bookingId, data),
|
||||||
@@ -473,25 +485,6 @@ function BookingsPageContent() {
|
|||||||
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
|
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
|
||||||
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
|
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
|
||||||
</div>
|
</div>
|
||||||
{b.paymentIntent?.status !== 'SUCCEEDED' && canManage && (
|
|
||||||
<div className="mt-3 p-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20">
|
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-400 mb-2">
|
|
||||||
Payment not confirmed by vendor. If you have verified the payment was completed externally, force-confirm to confirm the booking and generate the ticket.
|
|
||||||
</p>
|
|
||||||
<ActionButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => forceConfirmMutation.mutate({ bookingId: b.id, data: {} })}
|
|
||||||
disabled={forceConfirmMutation.isPending}
|
|
||||||
>
|
|
||||||
{forceConfirmMutation.isPending ? 'Confirming…' : 'Force Confirm & Generate Ticket'}
|
|
||||||
</ActionButton>
|
|
||||||
{forceConfirmMutation.isError && (
|
|
||||||
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
|
||||||
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Seats / Passengers */}
|
{/* Seats / Passengers */}
|
||||||
@@ -517,7 +510,9 @@ function BookingsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
{isSeats && (
|
{isSeats && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-mono font-semibold">{p.seat?.seatNumber || p.seatId || '—'}</p>
|
<p className="text-sm font-mono font-semibold">
|
||||||
|
{[p.seat?.coach?.number || p.coach ? `Coach ${p.seat?.coach?.number || p.coach}` : null, p.seat?.seatNumber || p.seatNumber ? `Seat ${p.seat?.seatNumber || p.seatNumber}` : (p.seatId ? `Seat ${p.seatId.slice(0, 8)}` : '—')].filter(Boolean).join(' · ')}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">{formatCurrency(p.fareMinor ?? 0, b.currency || 'ETB')}</p>
|
<p className="text-xs text-muted-foreground">{formatCurrency(p.fareMinor ?? 0, b.currency || 'ETB')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -564,7 +559,7 @@ function BookingsPageContent() {
|
|||||||
{/* Generate Ticket Modal */}
|
{/* Generate Ticket Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={!!generateTicketBooking}
|
isOpen={!!generateTicketBooking}
|
||||||
onClose={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); }}
|
onClose={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); smartAssignMutation.reset(); }}
|
||||||
title="Generate Ticket"
|
title="Generate Ticket"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
@@ -625,16 +620,31 @@ function BookingsPageContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{forceConfirmMutation.isError && (
|
{(forceConfirmMutation.isError || smartAssignMutation.isError) && (() => {
|
||||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
|
const e = (forceConfirmMutation.error ?? smartAssignMutation.error) as any;
|
||||||
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
|
const m = e?.response?.data?.message;
|
||||||
</div>
|
const msg = Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment';
|
||||||
)}
|
const isConflict = e?.response?.status === 409 || msg?.toLowerCase().includes('seat');
|
||||||
|
const isFullyBooked = msg?.toLowerCase().includes('no available seats');
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
|
||||||
|
<p className="font-semibold mb-1">
|
||||||
|
{isFullyBooked ? '🚫 Schedule Fully Booked' : isConflict ? '⚠️ Seat Conflict Detected' : 'Error'}
|
||||||
|
</p>
|
||||||
|
<p>{msg}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 text-sm">
|
||||||
|
<p className="font-semibold text-amber-800 dark:text-amber-300 mb-0.5">Seat auto-assignment</p>
|
||||||
|
<p className="text-amber-700 dark:text-amber-400">The system will automatically assign the best available seat and generate the ticket upon confirmation.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
|
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); }}
|
onClick={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); smartAssignMutation.reset(); }}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
@@ -642,18 +652,12 @@ function BookingsPageContent() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGenerateTicketTouched({ paymentReference: true, paymentMethod: true });
|
setGenerateTicketTouched({ paymentReference: true, paymentMethod: true });
|
||||||
if (!generateTicketForm.paymentReference || !generateTicketForm.paymentMethod) return;
|
if (!generateTicketForm.paymentReference || !generateTicketForm.paymentMethod) return;
|
||||||
forceConfirmMutation.mutate({
|
forceConfirmMutation.reset();
|
||||||
bookingId: generateTicketBooking.id,
|
smartAssignMutation.mutate(generateTicketBooking.id);
|
||||||
data: {
|
|
||||||
paymentReference: generateTicketForm.paymentReference,
|
|
||||||
paymentMethod: generateTicketForm.paymentMethod,
|
|
||||||
notes: generateTicketForm.notes || undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
disabled={forceConfirmMutation.isPending}
|
disabled={forceConfirmMutation.isPending || smartAssignMutation.isPending}
|
||||||
>
|
>
|
||||||
{forceConfirmMutation.isPending ? 'Generating…' : 'Confirm & Generate Ticket'}
|
{(forceConfirmMutation.isPending || smartAssignMutation.isPending) ? 'Generating…' : 'Confirm & Generate Ticket'}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,62 +3,103 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
import { Ticket, Users, DollarSign, AlertCircle, Calendar } from 'lucide-react';
|
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight } from 'lucide-react';
|
||||||
import StatCard from '@/components/dashboard/StatCard';
|
|
||||||
import DataTable from '@/components/ui/DataTable';
|
|
||||||
import Badge from '@/components/ui/Badge';
|
|
||||||
import { dashboardApi } from '@/lib/api/dashboard';
|
import { dashboardApi } from '@/lib/api/dashboard';
|
||||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||||
|
|
||||||
// Mock data for fallback when API fails
|
function StatCard({
|
||||||
const MOCK_STATS = {
|
icon, iconBg, label, total, loading, rows, href,
|
||||||
totalBookings: 1247,
|
}: {
|
||||||
totalRevenue: 892450,
|
icon: React.ReactNode;
|
||||||
totalPassengers: 2156,
|
iconBg: string;
|
||||||
};
|
label: string;
|
||||||
|
total: number;
|
||||||
|
loading: boolean;
|
||||||
|
rows: { label: string; value: number; icon?: React.ReactNode; href: string }[];
|
||||||
|
href: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="card flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`rounded-lg ${iconBg} p-1.5`}>{icon}</div>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-foreground tabular-nums">
|
||||||
|
{loading ? '—' : total.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.label} className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">{r.icon}{r.label}</span>
|
||||||
|
<Link href={r.href} className="text-sm font-semibold text-foreground tabular-nums hover:text-primary transition-colors">
|
||||||
|
{loading ? '—' : r.value.toLocaleString()}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Link href={href} className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
|
||||||
|
View all <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const MOCK_RECENT_BOOKINGS = [
|
function RevenueSection({
|
||||||
{
|
label, bookingCount, rows, subtotal, loading, renderRow,
|
||||||
id: '1',
|
}: {
|
||||||
bookingRef: 'BK-2024-001',
|
label: React.ReactNode;
|
||||||
passenger: { fullName: 'John Doe' },
|
bookingCount: number;
|
||||||
totalMinor: 125000,
|
rows: { currency: string; totalMinor: number }[];
|
||||||
currency: 'ETB',
|
subtotal: number;
|
||||||
status: 'CONFIRMED',
|
loading: boolean;
|
||||||
createdAt: new Date().toISOString()
|
renderRow: (r: { currency: string; totalMinor: number }) => React.ReactNode;
|
||||||
},
|
}) {
|
||||||
{
|
return (
|
||||||
id: '2',
|
<div className="flex flex-col gap-2">
|
||||||
bookingRef: 'BK-2024-002',
|
<div className="flex items-center justify-between mb-1">
|
||||||
passenger: { fullName: 'Jane Smith' },
|
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
totalMinor: 85000,
|
{label}
|
||||||
currency: 'ETB',
|
</span>
|
||||||
status: 'PENDING',
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
createdAt: new Date().toISOString()
|
{loading ? '—' : bookingCount.toLocaleString()} bookings
|
||||||
}
|
</span>
|
||||||
];
|
</div>
|
||||||
|
{rows.length === 0
|
||||||
|
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||||
|
: rows.map(renderRow)}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
|
||||||
|
<span className="text-sm font-bold text-foreground tabular-nums">{formatCurrency(subtotal, 'ETB')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DashboardPageContent() {
|
function DashboardPageContent() {
|
||||||
|
const { data: exchangeRates = [] } = useQuery<any[]>({
|
||||||
|
queryKey: ['currencies'],
|
||||||
|
queryFn: () => apiClient.get('/currencies'),
|
||||||
|
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toEtbRate = (currency: string): number | null => {
|
||||||
|
if (currency === 'ETB') return 1;
|
||||||
|
const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency);
|
||||||
|
return r ? 1 / r.rate : null;
|
||||||
|
};
|
||||||
|
|
||||||
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
|
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
|
||||||
queryKey: ['dashboard-stats'],
|
queryKey: ['backoffice-stats'],
|
||||||
queryFn: dashboardApi.getStats,
|
queryFn: dashboardApi.getBackofficeStats,
|
||||||
retry: 1,
|
|
||||||
staleTime: 60000, // 1 minute
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery<any[]>({
|
|
||||||
queryKey: ['recent-bookings'],
|
|
||||||
queryFn: () => dashboardApi.getRecentBookings(10),
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
|
|
||||||
queryKey: ['upcoming-trips'],
|
|
||||||
queryFn: () => dashboardApi.getUpcomingTrips(5),
|
|
||||||
retry: 1,
|
retry: 1,
|
||||||
|
staleTime: 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: paymentMethods } = useQuery({
|
const { data: paymentMethods } = useQuery({
|
||||||
@@ -67,57 +108,38 @@ function DashboardPageContent() {
|
|||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use actual data or fallback to mock/empty states
|
const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
|
||||||
const displayStats = stats || (statsError ? MOCK_STATS : null);
|
rows.reduce((sum, { currency, totalMinor }) => {
|
||||||
const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData :
|
const rate = toEtbRate(currency);
|
||||||
(bookingsError ? MOCK_RECENT_BOOKINGS : []);
|
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const bookingColumns = [
|
const normalRows = stats?.revenueByCurrency ?? [];
|
||||||
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
|
const packageRows = stats?.packageRevenueByCurrency ?? [];
|
||||||
{
|
const normalGrand = calcGrand(normalRows);
|
||||||
key: 'passenger',
|
const packageGrand = calcGrand(packageRows);
|
||||||
label: 'Passenger',
|
const overallGrand = normalGrand + packageGrand;
|
||||||
render: (item: any) => {
|
|
||||||
if (item.passenger?.fullName) {
|
|
||||||
return item.passenger.fullName;
|
|
||||||
}
|
|
||||||
if (item.contactEmail) {
|
|
||||||
return item.contactEmail;
|
|
||||||
}
|
|
||||||
if (item.contactPhone) {
|
|
||||||
return item.contactPhone;
|
|
||||||
}
|
|
||||||
return 'N/A';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
label: 'Status',
|
|
||||||
render: (item: any) => (
|
|
||||||
<Badge variant="status" status={item.status}>
|
|
||||||
{item.status}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
|
|
||||||
];
|
|
||||||
|
|
||||||
const tripColumns = [
|
const renderRevenueRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
|
||||||
{ key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name },
|
const rate = toEtbRate(currency);
|
||||||
{ key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name} → ${item.destinationStation?.name || item.destination?.name}` },
|
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
|
||||||
{ key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) },
|
return (
|
||||||
{ key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` },
|
<div key={currency} className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2">
|
||||||
{
|
<div className="flex items-center gap-1.5">
|
||||||
key: 'status',
|
<Banknote className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
label: 'Status',
|
<span className="text-sm font-medium text-foreground">{currency}</span>
|
||||||
render: (item: any) => (
|
</div>
|
||||||
<Badge variant="status" status={item.status}>
|
<span className="text-sm font-semibold text-foreground tabular-nums">
|
||||||
{item.status}
|
{formatCurrency(totalMinor, currency)}
|
||||||
</Badge>
|
{currency !== 'ETB' && etbMinor !== null && (
|
||||||
)
|
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
||||||
},
|
({formatCurrency(etbMinor, 'ETB')})
|
||||||
];
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
@@ -126,43 +148,105 @@ function DashboardPageContent() {
|
|||||||
<p className="text-muted-foreground mt-1">Welcome back! Here's your operational summary.</p>
|
<p className="text-muted-foreground mt-1">Welcome back! Here's your operational summary.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Alert */}
|
{statsError && (
|
||||||
{(statsError || bookingsError) && (
|
|
||||||
<div className="rounded-lg border border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/30 p-4">
|
<div className="rounded-lg border border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/30 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-orange-800 dark:text-orange-200">
|
<h3 className="font-semibold text-orange-800 dark:text-orange-200">Some data may be outdated</h3>
|
||||||
Some data may be outdated
|
<p className="text-sm text-orange-700 dark:text-orange-300">Unable to fetch live data. Showing cached or sample information.</p>
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-orange-700 dark:text-orange-300">
|
|
||||||
Unable to fetch live data. Showing cached or sample information.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Primary Metrics */}
|
{/* Stat cards */}
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Bookings"
|
icon={<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
|
||||||
value={statsLoading ? '...' : (displayStats?.totalBookings || 0).toLocaleString()}
|
iconBg="bg-blue-100 dark:bg-blue-900/30"
|
||||||
icon={Ticket}
|
label="Bookings"
|
||||||
color="blue"
|
total={stats?.totalBookings ?? 0}
|
||||||
|
loading={statsLoading}
|
||||||
|
href="/bookings"
|
||||||
|
rows={[
|
||||||
|
{ label: 'Regular', value: stats?.totalNormalBookings ?? 0, href: '/bookings' },
|
||||||
|
{ label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Revenue"
|
icon={<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />}
|
||||||
value={statsLoading ? '...' : formatCurrency(displayStats?.totalRevenue || 0, 'ETB')}
|
iconBg="bg-emerald-100 dark:bg-emerald-900/30"
|
||||||
icon={DollarSign}
|
label="Tickets"
|
||||||
color="green"
|
total={stats?.totalTickets ?? 0}
|
||||||
/>
|
loading={statsLoading}
|
||||||
<StatCard
|
href="/tickets"
|
||||||
title="Total Passengers"
|
rows={[
|
||||||
value={statsLoading ? '...' : (displayStats?.totalPassengers || 0).toLocaleString()}
|
{ label: 'Regular', value: stats?.totalNormalTickets ?? 0, href: '/tickets' },
|
||||||
icon={Users}
|
{ label: 'Package', value: stats?.totalPackageTickets ?? 0, href: '/tickets' },
|
||||||
color="purple"
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Revenue card */}
|
||||||
|
<div className="card flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
|
||||||
|
<Banknote className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue</span>
|
||||||
|
</div>
|
||||||
|
{statsLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
|
||||||
|
{formatCurrency(overallGrand, 'ETB')}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Regular</p>
|
||||||
|
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(normalGrand, 'ETB')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Package</p>
|
||||||
|
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(packageGrand, 'ETB')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/payments" className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
|
||||||
|
View payments <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revenue breakdown */}
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">Revenue Breakdown</h2>
|
||||||
|
{statsLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||||
|
) : !normalRows.length && !packageRows.length ? (
|
||||||
|
<p className="text-muted-foreground text-sm">No revenue data yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<RevenueSection
|
||||||
|
label="Regular"
|
||||||
|
bookingCount={stats?.totalNormalBookings ?? 0}
|
||||||
|
rows={normalRows}
|
||||||
|
subtotal={normalGrand}
|
||||||
|
loading={statsLoading}
|
||||||
|
renderRow={renderRevenueRow}
|
||||||
|
/>
|
||||||
|
<RevenueSection
|
||||||
|
label="Package"
|
||||||
|
bookingCount={stats?.totalPackageBookings ?? 0}
|
||||||
|
rows={packageRows}
|
||||||
|
subtotal={packageGrand}
|
||||||
|
loading={statsLoading}
|
||||||
|
renderRow={renderRevenueRow}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Methods Distribution */}
|
{/* Payment Methods Distribution */}
|
||||||
@@ -171,16 +255,8 @@ function DashboardPageContent() {
|
|||||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
|
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie data={paymentMethods} dataKey="count" nameKey="method" cx="50%" cy="50%" outerRadius={80} label>
|
||||||
data={paymentMethods}
|
{paymentMethods.map((_: any, index: number) => (
|
||||||
dataKey="count"
|
|
||||||
nameKey="method"
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
outerRadius={80}
|
|
||||||
label
|
|
||||||
>
|
|
||||||
{paymentMethods.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
@@ -189,35 +265,6 @@ function DashboardPageContent() {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent Bookings */}
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
|
|
||||||
<Ticket className="h-5 w-5" />
|
|
||||||
Recent Bookings
|
|
||||||
</h2>
|
|
||||||
<DataTable
|
|
||||||
data={recentBookings}
|
|
||||||
columns={bookingColumns}
|
|
||||||
loading={bookingsLoading}
|
|
||||||
emptyMessage="No recent bookings found"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upcoming Trips */}
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
|
|
||||||
<Calendar className="h-5 w-5" />
|
|
||||||
Upcoming Trips
|
|
||||||
</h2>
|
|
||||||
<DataTable
|
|
||||||
data={upcomingTrips || []}
|
|
||||||
columns={tripColumns}
|
|
||||||
loading={tripsLoading}
|
|
||||||
emptyMessage="No upcoming trips scheduled"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -228,4 +275,4 @@ export default function DashboardPage() {
|
|||||||
<DashboardPageContent />
|
<DashboardPageContent />
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import DataTable from '@/components/ui/DataTable';
|
|||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { excessBaggageApi } from '@/lib/api';
|
import { excessBaggageApi, apiClient } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
@@ -34,6 +34,15 @@ export default function ExcessBaggagePage() {
|
|||||||
const [resendSuccess, setResendSuccess] = useState(false);
|
const [resendSuccess, setResendSuccess] = useState(false);
|
||||||
const [resendError, setResendError] = useState<string | null>(null);
|
const [resendError, setResendError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: allowancesData } = useQuery({
|
||||||
|
queryKey: ['baggage-allowances'],
|
||||||
|
queryFn: () => apiClient.get<any>('/agents/excess-baggage/allowances'),
|
||||||
|
});
|
||||||
|
const allowances: any[] = Array.isArray(allowancesData)
|
||||||
|
? allowancesData
|
||||||
|
: (allowancesData as any)?.items ?? (allowancesData as any)?.data ?? [];
|
||||||
|
const excessRate = allowances[0] ?? null;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['excess-baggage', filters],
|
queryKey: ['excess-baggage', filters],
|
||||||
queryFn: () => excessBaggageApi.getAll({
|
queryFn: () => excessBaggageApi.getAll({
|
||||||
@@ -229,58 +238,76 @@ export default function ExcessBaggagePage() {
|
|||||||
Logging as agent: <span className="font-semibold text-foreground">{user.fullName}</span>
|
Logging as agent: <span className="font-semibold text-foreground">{user.fullName}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
{!excessRate ? (
|
||||||
<label className="label">Booking ID</label>
|
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 text-sm text-amber-800 dark:text-amber-200">
|
||||||
<input
|
No excess luggage rate configured. Please set a rate in Tariff Rates before logging.
|
||||||
className="input"
|
</div>
|
||||||
placeholder="Booking UUID"
|
) : (
|
||||||
value={logForm.bookingId}
|
<>
|
||||||
onChange={(e) => setLogForm({ ...logForm, bookingId: e.target.value })}
|
<div className="rounded-lg bg-muted/50 px-3 py-2 text-sm">
|
||||||
/>
|
Rate: <span className="font-semibold">{(excessRate.excessFeePerKg / 100).toFixed(2)} ETB/kg</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Excess Weight (kg)</label>
|
<label className="label">Booking ID</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
className="input"
|
||||||
min="1"
|
placeholder="Booking UUID"
|
||||||
className="input"
|
value={logForm.bookingId}
|
||||||
placeholder="e.g. 5"
|
onChange={(e) => setLogForm({ ...logForm, bookingId: e.target.value })}
|
||||||
value={logForm.excessWeightKg}
|
/>
|
||||||
onChange={(e) => setLogForm({ ...logForm, excessWeightKg: e.target.value })}
|
</div>
|
||||||
/>
|
<div>
|
||||||
</div>
|
<label className="label">Excess Weight (kg)</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<input
|
||||||
<input
|
type="number"
|
||||||
type="checkbox"
|
min="1"
|
||||||
checked={logForm.collectCash}
|
className="input"
|
||||||
onChange={(e) => setLogForm({ ...logForm, collectCash: e.target.checked })}
|
placeholder="e.g. 5"
|
||||||
/>
|
value={logForm.excessWeightKg}
|
||||||
Collect cash now (no payment link sent)
|
onChange={(e) => setLogForm({ ...logForm, excessWeightKg: e.target.value })}
|
||||||
</label>
|
/>
|
||||||
{!logForm.collectCash && (
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
{logForm.excessWeightKg && (
|
||||||
A payment link will be sent to the passenger's email and phone on file.
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
Estimated charge: <span className="font-semibold">{((excessRate.excessFeePerKg / 100) * parseInt(logForm.excessWeightKg || '0')).toFixed(2)} ETB</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={logForm.collectCash}
|
||||||
|
onChange={(e) => setLogForm({ ...logForm, collectCash: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Collect cash now (no payment link sent)
|
||||||
|
</label>
|
||||||
|
{!logForm.collectCash && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A payment link will be sent to the passenger's email and phone on file.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{logError && <p className="text-sm text-red-600 dark:text-red-400">{logError}</p>}
|
{logError && <p className="text-sm text-red-600 dark:text-red-400">{logError}</p>}
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<ActionButton variant="secondary" onClick={() => setLogModal(false)}>Cancel</ActionButton>
|
<ActionButton variant="secondary" onClick={() => setLogModal(false)}>Cancel</ActionButton>
|
||||||
<ActionButton
|
{excessRate && (
|
||||||
loading={logMutation.isPending}
|
<ActionButton
|
||||||
onClick={() => {
|
loading={logMutation.isPending}
|
||||||
if (!logForm.bookingId.trim() || !logForm.excessWeightKg) {
|
onClick={() => {
|
||||||
setLogError('Booking ID and excess weight are required');
|
if (!logForm.bookingId.trim() || !logForm.excessWeightKg) {
|
||||||
return;
|
setLogError('Booking ID and excess weight are required');
|
||||||
}
|
return;
|
||||||
logMutation.mutate({
|
}
|
||||||
bookingId: logForm.bookingId.trim(),
|
logMutation.mutate({
|
||||||
excessWeightKg: parseInt(logForm.excessWeightKg),
|
bookingId: logForm.bookingId.trim(),
|
||||||
collectCash: logForm.collectCash,
|
excessWeightKg: parseInt(logForm.excessWeightKg),
|
||||||
});
|
collectCash: logForm.collectCash,
|
||||||
}}
|
});
|
||||||
>
|
}}
|
||||||
{logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'}
|
>
|
||||||
</ActionButton>
|
{logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'}
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -663,10 +663,6 @@ export default function SeatsPage() {
|
|||||||
<div className="w-5 h-5 rounded bg-gray-500"></div>
|
<div className="w-5 h-5 rounded bg-gray-500"></div>
|
||||||
<span className="text-sm text-muted-foreground">Blocked</span>
|
<span className="text-sm text-muted-foreground">Blocked</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-5 h-5 rounded bg-orange-500"></div>
|
|
||||||
<span className="text-sm text-muted-foreground">Under Maintenance</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
|
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
|
||||||
<span className="text-sm text-muted-foreground">Removed</span>
|
<span className="text-sm text-muted-foreground">Removed</span>
|
||||||
|
|||||||
@@ -26,20 +26,19 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!form.seatClassId || !form.maxWeightKg || !form.maxPiecesCount || !form.excessFeePerKg) {
|
if (!form.excessFeePerKg) {
|
||||||
setError('All fields are required'); return;
|
setError('Excess fee per kg is required'); return;
|
||||||
}
|
}
|
||||||
const payload = {
|
const feeMinor = Math.round(parseFloat(form.excessFeePerKg) * 100);
|
||||||
seatClassId: form.seatClassId,
|
|
||||||
maxWeightKg: parseInt(form.maxWeightKg),
|
|
||||||
maxPiecesCount: parseInt(form.maxPiecesCount),
|
|
||||||
excessFeePerKg: Math.round(parseFloat(form.excessFeePerKg) * 100),
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await update.mutateAsync({ id: editing.id, ...payload });
|
await update.mutateAsync({ id: editing.id, excessFeePerKg: feeMinor });
|
||||||
} else {
|
} else {
|
||||||
await create.mutateAsync(payload);
|
// Create a rule for every seat class that doesn't already have one
|
||||||
|
const existingClassIds = new Set(allowances.map((a: BaggageAllowance) => a.seatClassId));
|
||||||
|
const missing = allClasses.filter(sc => !existingClassIds.has(sc.id));
|
||||||
|
if (!missing.length) { setError('All seat classes already have a rule. Use Edit to update.'); return; }
|
||||||
|
await Promise.all(missing.map(sc => create.mutateAsync({ seatClassId: sc.id, excessFeePerKg: feeMinor })));
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -58,9 +57,7 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
|
|||||||
<DataTable
|
<DataTable
|
||||||
data={allowances}
|
data={allowances}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'seatClass', label: 'Seat Class', render: (a: BaggageAllowance) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
|
{ key: 'excessFeePerKg', label: 'Fare per kg (ETB)', render: (a: BaggageAllowance) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
|
||||||
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: BaggageAllowance) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
|
|
||||||
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: BaggageAllowance) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
|
|
||||||
]}
|
]}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
@@ -74,36 +71,18 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
|
|||||||
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (a: BaggageAllowance) => setDeleteConfirm({ isOpen: true, id: a.id }) },
|
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (a: BaggageAllowance) => setDeleteConfirm({ isOpen: true, id: a.id }) },
|
||||||
]}
|
]}
|
||||||
loading={false}
|
loading={false}
|
||||||
emptyMessage='No baggage allowance rules defined. Click "Add Allowance Rule" to create one.'
|
emptyMessage='No excess luggage tariff rates defined. Click "Add Luggage Rate" to create one.'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen || !!editing}
|
isOpen={isOpen || !!editing}
|
||||||
onClose={() => { resetForm(); onClose(); }}
|
onClose={() => { resetForm(); onClose(); }}
|
||||||
title={editing ? 'Edit Allowance Rule' : 'Add Allowance Rule'}
|
title={editing ? 'Edit Excess Luggage Rate' : 'Add Excess Luggage Rate'}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{error && <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{error}</div>}
|
{error && <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{error}</div>}
|
||||||
<div>
|
|
||||||
<label className="label">Seat Class *</label>
|
|
||||||
<select value={form.seatClassId} onChange={e => setForm({ ...form, seatClassId: e.target.value })} className="input w-full" disabled={!!editing}>
|
|
||||||
<option value="">Select seat class...</option>
|
|
||||||
{allClasses.map(sc => <option key={sc.id} value={sc.id}>{sc.name}</option>)}
|
|
||||||
</select>
|
|
||||||
{editing && <p className="text-xs text-muted-foreground mt-1">Seat class cannot be changed. Delete and recreate to change.</p>}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="label">Free Allowance (kg) *</label>
|
|
||||||
<input type="number" min="0" className="input w-full" placeholder="e.g. 20" value={form.maxWeightKg} onChange={e => setForm({ ...form, maxWeightKg: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Max Pieces *</label>
|
|
||||||
<input type="number" min="1" className="input w-full" placeholder="e.g. 2" value={form.maxPiecesCount} onChange={e => setForm({ ...form, maxPiecesCount: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Excess Fee per kg (ETB) *</label>
|
<label className="label">Excess Fee per kg (ETB) *</label>
|
||||||
<input type="number" min="0" step="0.01" className="input w-full" placeholder="e.g. 50.00" value={form.excessFeePerKg} onChange={e => setForm({ ...form, excessFeePerKg: e.target.value })} />
|
<input type="number" min="0" step="0.01" className="input w-full" placeholder="e.g. 50.00" value={form.excessFeePerKg} onChange={e => setForm({ ...form, excessFeePerKg: e.target.value })} />
|
||||||
|
|||||||
@@ -31,4 +31,7 @@ export const bookingsApi = {
|
|||||||
|
|
||||||
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
|
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
|
||||||
apiClient.post(`/payments/${bookingId}/force-confirm`, data),
|
apiClient.post(`/payments/${bookingId}/force-confirm`, data),
|
||||||
|
|
||||||
|
smartAssign: (bookingId: string) =>
|
||||||
|
apiClient.post(`/tickets/smart-assign/${bookingId}`, {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ import { apiClient } from '@/lib/api-client';
|
|||||||
import { DashboardStats, RevenueData } from '@/types';
|
import { DashboardStats, RevenueData } from '@/types';
|
||||||
|
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
|
getBackofficeStats: async () => {
|
||||||
|
const response = await apiClient.get<{
|
||||||
|
totalBookings: number;
|
||||||
|
totalNormalBookings: number;
|
||||||
|
totalPackageBookings: number;
|
||||||
|
totalTickets: number;
|
||||||
|
totalNormalTickets: number;
|
||||||
|
totalPackageTickets: number;
|
||||||
|
totalPassengers: number;
|
||||||
|
revenueByCurrency: { currency: string; totalMinor: number }[];
|
||||||
|
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
|
||||||
|
}>('/dashboard/backoffice-stats');
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
getStats: async () => {
|
getStats: async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch bookings and passengers data in parallel
|
// Fetch bookings and passengers data in parallel
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export const bookingsApi = {
|
|||||||
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
|
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
|
||||||
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
|
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
|
||||||
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
|
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
|
||||||
|
smartAssign: (bookingId: string) =>
|
||||||
|
apiClient.post<any>(`/tickets/smart-assign/${bookingId}`, {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Passengers API
|
// Passengers API
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ export const formatCurrency = (amount: number, currency: string = 'ETB'): string
|
|||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency,
|
currency,
|
||||||
|
currencyDisplay: 'code',
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
}).format(amount / 100);
|
}).format(amount / 100).replace(/^([A-Z]{3})/, '$1 ').trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDate = (date?: string | Date | null, formatStr: string = 'MMM dd, yyyy'): string => {
|
export const formatDate = (date?: string | Date | null, formatStr: string = 'MMM dd, yyyy'): string => {
|
||||||
|
|||||||
Reference in New Issue
Block a user