mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Package inquiry, UAT related update
This commit is contained in:
@@ -1417,6 +1417,7 @@ model TravelPackage {
|
||||
returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id])
|
||||
priceTiers PackagePriceTier[]
|
||||
bookings PackageBooking[]
|
||||
inquiries PackageInquiry[]
|
||||
|
||||
@@index([status, validFrom])
|
||||
@@schema("passenger")
|
||||
@@ -1434,6 +1435,7 @@ model PackagePriceTier {
|
||||
|
||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||
bookings PackageBooking[]
|
||||
inquiries PackageInquiry[]
|
||||
|
||||
@@unique([packageId, seatType])
|
||||
@@schema("passenger")
|
||||
@@ -1501,3 +1503,24 @@ model PackagePaymentIntent {
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model PackageInquiry {
|
||||
id String @id @default(uuid())
|
||||
packageId String
|
||||
priceTierId String?
|
||||
travelerCount Int
|
||||
contactName String
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
notes String?
|
||||
status String @default("NEW")
|
||||
enquiredAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||
priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id])
|
||||
|
||||
@@index([packageId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -80,15 +80,23 @@ export class BookingsController {
|
||||
@ApiOperation({
|
||||
description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
|
||||
@ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'returnLegStatus', required: false })
|
||||
@ApiQuery({ name: 'bookingType', required: false })
|
||||
@ApiQuery({ name: 'paymentStatus', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('returnLegStatus') returnLegStatus?: string,
|
||||
@Query('bookingType') bookingType?: string,
|
||||
@Query('paymentStatus') paymentStatus?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
@@ -96,6 +104,10 @@ export class BookingsController {
|
||||
search,
|
||||
status,
|
||||
returnLegStatus,
|
||||
bookingType,
|
||||
paymentStatus,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
|
||||
@@ -28,6 +28,10 @@ interface BookingFilters {
|
||||
search?: string;
|
||||
status?: string;
|
||||
returnLegStatus?: string;
|
||||
bookingType?: string;
|
||||
paymentStatus?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -195,7 +199,7 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters;
|
||||
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
@@ -227,6 +231,23 @@ export class BookingsService {
|
||||
|
||||
if (status) where.status = status;
|
||||
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
|
||||
if (bookingType) where.bookingType = bookingType;
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {
|
||||
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
|
||||
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
if (paymentStatus) {
|
||||
const statusMap: Record<string, string> = {
|
||||
PAID: 'SUCCEEDED',
|
||||
PENDING: 'REQUIRES_ACTION',
|
||||
FAILED: 'FAILED',
|
||||
REFUNDED: 'REFUNDED',
|
||||
};
|
||||
const mapped = statusMap[paymentStatus] ?? paymentStatus;
|
||||
where.paymentIntent = { is: { status: mapped } };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
|
||||
@@ -35,12 +35,16 @@ export class ExcessBaggageAgentController {
|
||||
getAll(
|
||||
@Query('status') status?: string,
|
||||
@Query('bookingRef') bookingRef?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.getAll({
|
||||
status,
|
||||
bookingRef,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : undefined,
|
||||
pageSize: pageSize ? parseInt(pageSize) : undefined,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { PaymentClientService } from '../payments/payment-client.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { EmailClientService } from '../notifications/email-client.service';
|
||||
import {
|
||||
LogExcessBaggageDto,
|
||||
WaiveChargeDto,
|
||||
@@ -30,6 +32,8 @@ export class ExcessBaggageService {
|
||||
private prisma: PrismaService,
|
||||
private paymentClient: PaymentClientService,
|
||||
private notifications: NotificationsService,
|
||||
private smsClient: SmsClientService,
|
||||
private emailClient: EmailClientService,
|
||||
) {}
|
||||
|
||||
async logCharge(dto: LogExcessBaggageDto) {
|
||||
@@ -101,23 +105,27 @@ export class ExcessBaggageService {
|
||||
const amountStr = (charge.totalMinor / 100).toFixed(2);
|
||||
const msg = `EDR: Excess baggage charge of ${amountStr} ETB for booking ${booking.bookingRef}. Pay here: ${payUrl} (valid 30 min)`;
|
||||
|
||||
const recipient = phone ?? email ?? booking.passengerId;
|
||||
try {
|
||||
await this.notifications['deliverSms'](recipient, msg);
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`);
|
||||
if (phone) {
|
||||
try {
|
||||
await this.smsClient.sendSms({ to: phone, message: msg });
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications['deliverEmail'](
|
||||
recipient,
|
||||
`EDR — Excess baggage payment required (${booking.bookingRef})`,
|
||||
msg,
|
||||
);
|
||||
await this.emailClient.sendEmail({
|
||||
to: email,
|
||||
subject: `EDR — Excess baggage payment required (${booking.bookingRef})`,
|
||||
text: msg,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`);
|
||||
}
|
||||
}
|
||||
if (!phone && !email) {
|
||||
this.logger.warn(`No contact info to send excess baggage payment link for charge ${charge.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getCharge(id: string) {
|
||||
@@ -227,14 +235,22 @@ export class ExcessBaggageService {
|
||||
async getAll(filters: {
|
||||
status?: string;
|
||||
bookingRef?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const { status, bookingRef, page = 1, pageSize = 20 } = filters;
|
||||
const { status, bookingRef, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = {};
|
||||
if (status) where.status = status;
|
||||
if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } };
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {
|
||||
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
|
||||
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.excessBaggageCharge.findMany({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
OnApplicationBootstrap,
|
||||
} from "@nestjs/common";
|
||||
import { ClientProxy } from "@nestjs/microservices";
|
||||
import * as sgMail from "@sendgrid/mail";
|
||||
import { SendEmail } from "./dtos/email.dto";
|
||||
|
||||
@Injectable()
|
||||
@@ -14,9 +15,15 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
@Inject("EMAIL_SERVICE")
|
||||
private readonly emailServiceClient: ClientProxy,
|
||||
) {}
|
||||
) {
|
||||
const apiKey = process.env.SENDGRID_API_KEY;
|
||||
if (apiKey) sgMail.setApiKey(apiKey);
|
||||
}
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
||||
private get sendgridEnabled() {
|
||||
return !!process.env.SENDGRID_API_KEY;
|
||||
}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (!this.enabled) return;
|
||||
@@ -29,22 +36,38 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async sendEmail(dto: SendEmail): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped EMAIL`);
|
||||
return { queued: false };
|
||||
if (this.enabled) {
|
||||
this.emailServiceClient.emit("send-email", {
|
||||
...dto,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
this.logger.log(
|
||||
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
|
||||
);
|
||||
this.logger.debug(
|
||||
`EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}"`,
|
||||
);
|
||||
return { queued: true };
|
||||
}
|
||||
this.emailServiceClient.emit("send-email", {
|
||||
...dto,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
// Fire-and-forget enqueue: this confirms the message was handed to RabbitMQ, NOT delivered.
|
||||
this.logger.log(
|
||||
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
|
||||
);
|
||||
// Recipient + content are PII — keep them at debug level only.
|
||||
this.logger.debug(
|
||||
`EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`,
|
||||
);
|
||||
return { queued: true };
|
||||
|
||||
if (this.sendgridEnabled) {
|
||||
try {
|
||||
await sgMail.send({
|
||||
to: dto.to,
|
||||
from: process.env.SENDGRID_FROM_EMAIL ?? "noreply@edr-platform.com",
|
||||
subject: dto.subject ?? "EDR Notification",
|
||||
text: dto.text ?? dto.body ?? "",
|
||||
...(dto.html ? { html: dto.html } : {}),
|
||||
});
|
||||
this.logger.log(`EMAIL sent via SendGrid to=${dto.to}`);
|
||||
return { queued: true };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`SendGrid send failed to=${dto.to}: ${err?.message}`);
|
||||
return { queued: false };
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn(`EMAIL not sent (no transport) — to=${dto.to} subject="${dto.subject ?? ""}"`);
|
||||
return { queued: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
|
||||
async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
|
||||
this.logger.warn(`SMS not sent (RabbitMQ disabled) — to=${dto.to} message="${dto.message}"`);
|
||||
return { queued: false };
|
||||
}
|
||||
this.smsClient.emit("send-sms", {
|
||||
@@ -51,7 +51,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
|
||||
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
|
||||
this.logger.warn(`BULK SMS not sent (RabbitMQ disabled) — ${dto.messages?.length ?? 0} messages skipped`);
|
||||
return { queued: false };
|
||||
}
|
||||
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request,
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PackagesService } from './packages.service';
|
||||
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto } from './packages.dto';
|
||||
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto } from './packages.dto';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
@@ -12,6 +12,42 @@ import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
export class PackagesController {
|
||||
constructor(private readonly service: PackagesService) {}
|
||||
|
||||
@Post('inquiries')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Submit a package inquiry (public)' })
|
||||
createInquiry(@Body() dto: CreateInquiryDto) {
|
||||
return this.service.createInquiry(dto);
|
||||
}
|
||||
|
||||
@Get('inquiries')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all inquiries (backoffice)' })
|
||||
listInquiries(
|
||||
@Query('packageId') packageId?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.listInquiries({ packageId, status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20 });
|
||||
}
|
||||
|
||||
@Patch('inquiries/:id/status')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update inquiry status (backoffice)' })
|
||||
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
|
||||
return this.service.updateInquiryStatus(id, dto.status);
|
||||
}
|
||||
|
||||
@Delete('inquiries/:id')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete inquiry (backoffice)' })
|
||||
deleteInquiry(@Param('id') id: string) {
|
||||
return this.service.deleteInquiry(id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List active packages' })
|
||||
|
||||
@@ -16,6 +16,20 @@ export class CreatePriceTierDto {
|
||||
@IsInt() @Min(0) availableSeats: number;
|
||||
}
|
||||
|
||||
export class CreateInquiryDto {
|
||||
@ApiProperty() @IsUUID() packageId: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsUUID() priceTierId?: string;
|
||||
@ApiProperty({ example: 2 }) @IsInt() @Min(1) travelerCount: number;
|
||||
@ApiProperty() @IsString() contactName: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() contactEmail?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() contactPhone?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateInquiryStatusDto {
|
||||
@ApiProperty({ example: 'CONTACTED' }) @IsString() status: string;
|
||||
}
|
||||
|
||||
export class UpdatePriceTierDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() seatType?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() label?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto } from './packages.dto';
|
||||
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
function generateRef(): string {
|
||||
@@ -17,6 +17,53 @@ export class PackagesService {
|
||||
private readonly currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
async createInquiry(dto: CreateInquiryDto) {
|
||||
return this.prisma.packageInquiry.create({
|
||||
data: {
|
||||
packageId: dto.packageId,
|
||||
priceTierId: dto.priceTierId ?? null,
|
||||
travelerCount: dto.travelerCount,
|
||||
contactName: dto.contactName,
|
||||
contactEmail: dto.contactEmail ?? null,
|
||||
contactPhone: dto.contactPhone ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
enquiredAt: new Date(),
|
||||
},
|
||||
include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async listInquiries({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) {
|
||||
const where: any = {};
|
||||
if (packageId) where.packageId = packageId;
|
||||
if (status) where.status = status;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.packageInquiry.findMany({
|
||||
where,
|
||||
include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true, priceMinor: true } } },
|
||||
orderBy: { enquiredAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.packageInquiry.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async updateInquiryStatus(id: string, status: string) {
|
||||
const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } });
|
||||
if (!inquiry) throw new NotFoundException('Inquiry not found');
|
||||
return this.prisma.packageInquiry.update({ where: { id }, data: { status } });
|
||||
}
|
||||
|
||||
async deleteInquiry(id: string) {
|
||||
const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } });
|
||||
if (!inquiry) throw new NotFoundException('Inquiry not found');
|
||||
await this.prisma.packageInquiry.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
listActive() {
|
||||
const now = new Date();
|
||||
return this.prisma.travelPackage.findMany({
|
||||
|
||||
@@ -24,19 +24,31 @@ export class PassengersController {
|
||||
summary: 'List all passengers with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of passengers with search filters'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' })
|
||||
@ApiQuery({ name: 'verified', required: false, description: 'Filter by verification status' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'verified', required: false })
|
||||
@ApiQuery({ name: 'gender', required: false })
|
||||
@ApiQuery({ name: 'nationality', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('verified') verified?: string,
|
||||
@Query('gender') gender?: string,
|
||||
@Query('nationality') nationality?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
search,
|
||||
verified: verified ? verified === 'true' : undefined,
|
||||
verified: verified ? verified === 'true' : undefined,
|
||||
gender,
|
||||
nationality,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
|
||||
@@ -8,6 +8,10 @@ import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
interface PassengerFilters {
|
||||
search?: string;
|
||||
verified?: boolean;
|
||||
gender?: string;
|
||||
nationality?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -29,7 +33,7 @@ export class PassengersService {
|
||||
) {}
|
||||
|
||||
async findAll(filters: PassengerFilters = {}) {
|
||||
const { search, verified, page = 1, pageSize = 20 } = filters;
|
||||
const { search, verified, gender, nationality, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
@@ -48,6 +52,21 @@ export class PassengersService {
|
||||
where.user = { ...(where.user ?? {}), faydaVerified: verified };
|
||||
}
|
||||
|
||||
if (gender) {
|
||||
where.user = { ...(where.user ?? {}), gender };
|
||||
}
|
||||
|
||||
if (nationality) {
|
||||
where.user = { ...(where.user ?? {}), nationality: { contains: nationality, mode: 'insensitive' } };
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {
|
||||
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
|
||||
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.passenger.findMany({
|
||||
where,
|
||||
|
||||
@@ -37,6 +37,8 @@ export class TicketsController {
|
||||
@ApiQuery({ name: 'originStationId', required: false })
|
||||
@ApiQuery({ name: 'destinationStationId', required: false })
|
||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'skip', required: false })
|
||||
@ApiQuery({ name: 'take', required: false })
|
||||
listTickets(
|
||||
@@ -45,6 +47,8 @@ export class TicketsController {
|
||||
@Query('originStationId') originStationId?: string,
|
||||
@Query('destinationStationId') destinationStationId?: string,
|
||||
@Query('arrivalDate') arrivalDate?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('skip') skip?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
@@ -54,6 +58,8 @@ export class TicketsController {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
arrivalDate,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
take: take ? parseInt(take) : 50,
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ export class TicketsService {
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; skip: number; take: number }) {
|
||||
const where: any = {};
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
@@ -31,7 +31,7 @@ export class TicketsService {
|
||||
];
|
||||
}
|
||||
if (filters.status) {
|
||||
where.booking = { ...where.booking, status: filters.status };
|
||||
where.status = filters.status;
|
||||
}
|
||||
if (filters.originStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
|
||||
@@ -45,6 +45,12 @@ export class TicketsService {
|
||||
end.setDate(end.getDate() + 1);
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
|
||||
}
|
||||
if (filters.dateFrom || filters.dateTo) {
|
||||
where.issuedAt = {
|
||||
...(filters.dateFrom ? { gte: new Date(filters.dateFrom) } : {}),
|
||||
...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
const [tickets, total] = await Promise.all([
|
||||
this.prisma.ticket.findMany({
|
||||
where,
|
||||
|
||||
@@ -47,8 +47,14 @@ export default function BookingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['bookings', filters],
|
||||
queryFn: () => bookingsApi.getAll(filters),
|
||||
queryKey: ['bookings', filters, extraFilters],
|
||||
queryFn: () => bookingsApi.getAll({
|
||||
...filters,
|
||||
...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }),
|
||||
...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }),
|
||||
...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }),
|
||||
...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }),
|
||||
}),
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
@@ -257,8 +263,8 @@ export default function BookingsPage() {
|
||||
<select className="input" value={extraFilters.paymentStatus}
|
||||
onChange={(e) => setExtraFilters({ ...extraFilters, paymentStatus: e.target.value })}>
|
||||
<option value="">All Payments</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="PAID">Paid</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
<option value="REFUNDED">Refunded</option>
|
||||
</select>
|
||||
|
||||
@@ -20,14 +20,21 @@ const STATUS_VARIANT: Record<string, any> = {
|
||||
|
||||
export default function ExcessBaggagePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [filters, setFilters] = useState({ status: '', bookingRef: '', page: '1' });
|
||||
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
const [waiveModal, setWaiveModal] = useState<any>(null);
|
||||
const [waiveReason, setWaiveReason] = useState('');
|
||||
const [waiveError, setWaiveError] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['excess-baggage', filters],
|
||||
queryFn: () => excessBaggageApi.getAll({ status: filters.status || undefined, bookingRef: filters.bookingRef || undefined, page: filters.page }),
|
||||
queryFn: () => excessBaggageApi.getAll({
|
||||
status: filters.status || undefined,
|
||||
bookingRef: filters.bookingRef || undefined,
|
||||
dateFrom: filters.dateFrom || undefined,
|
||||
dateTo: filters.dateTo || undefined,
|
||||
page: filters.page,
|
||||
}),
|
||||
});
|
||||
|
||||
const waiveMutation = useMutation({
|
||||
@@ -104,14 +111,14 @@ export default function ExcessBaggagePage() {
|
||||
icon: Send,
|
||||
variant: 'secondary' as const,
|
||||
onClick: (c: any) => resendMutation.mutate(c.id),
|
||||
hidden: (c: any) => c.status !== 'PENDING',
|
||||
show: (c: any) => c.status === 'PENDING',
|
||||
},
|
||||
{
|
||||
label: 'Waive',
|
||||
icon: RefreshCw,
|
||||
variant: 'secondary' as const,
|
||||
onClick: (c: any) => { setWaiveModal(c); setWaiveReason(''); setWaiveError(null); },
|
||||
hidden: (c: any) => ['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status),
|
||||
show: (c: any) => !['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -125,31 +132,41 @@ export default function ExcessBaggagePage() {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="label">Booking Ref</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search by booking ref…"
|
||||
value={filters.bookingRef}
|
||||
onChange={(e) => setFilters({ ...filters, bookingRef: e.target.value, page: '1' })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: '1' })}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex-1 min-w-48">
|
||||
<input className="input" placeholder="Search by booking ref…"
|
||||
value={filters.bookingRef}
|
||||
onChange={(e) => setFilters({ ...filters, bookingRef: e.target.value, page: '1' })} />
|
||||
</div>
|
||||
<select className="input w-44" value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: '1' })}>
|
||||
<option value="">All Status</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="PAID">Paid</option>
|
||||
<option value="CASH_COLLECTED">Cash Collected</option>
|
||||
<option value="EXPIRED">Expired</option>
|
||||
<option value="WAIVED">Waived</option>
|
||||
</select>
|
||||
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
|
||||
onClick={() => setShowExtraFilters(v => !v)}>
|
||||
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
|
||||
</button>
|
||||
</div>
|
||||
{showExtraFilters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||||
<div>
|
||||
<label className="label">Date From</label>
|
||||
<input type="date" className="input" value={filters.dateFrom}
|
||||
onChange={(e) => setFilters({ ...filters, dateFrom: e.target.value, page: '1' })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To</label>
|
||||
<input type="date" className="input" value={filters.dateTo}
|
||||
onChange={(e) => setFilters({ ...filters, dateTo: e.target.value, page: '1' })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function PackageInquiriesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { packageInquiriesApi, packagesApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED'];
|
||||
|
||||
const statusVariant: Record<string, string> = {
|
||||
NEW: 'info',
|
||||
CONTACTED: 'warning',
|
||||
CONVERTED: 'success',
|
||||
CLOSED: 'default',
|
||||
};
|
||||
|
||||
export default function PackageInquiriesPage() {
|
||||
const [filters, setFilters] = useState({ packageId: '', status: '' });
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['package-inquiries', filters],
|
||||
queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }),
|
||||
});
|
||||
|
||||
const { data: packagesData } = useQuery({
|
||||
queryKey: ['packages-all-simple'],
|
||||
queryFn: () => packagesApi.getAll({ pageSize: 100 }),
|
||||
});
|
||||
|
||||
const packages: any[] = packagesData?.items || [];
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
packageInquiriesApi.updateStatus(id, status),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['package-inquiries'] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => packageInquiriesApi.remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['package-inquiries'] });
|
||||
setDeleteConfirm(null);
|
||||
setDeleteError(null);
|
||||
},
|
||||
onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete'),
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'contact',
|
||||
label: 'Contact',
|
||||
render: (row: any) => (
|
||||
<div>
|
||||
<div className="font-semibold">{row.contactName}</div>
|
||||
<div className="text-xs text-muted-foreground">{row.contactEmail || row.contactPhone || '—'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'package',
|
||||
label: 'Package',
|
||||
render: (row: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.package?.name || '—'}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{row.package?.code}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'priceTier',
|
||||
label: 'Price Tier',
|
||||
render: (row: any) => row.priceTier ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium">{row.priceTier.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatCurrency(row.priceTier.priceMinor, 'ETB')} / person</div>
|
||||
</div>
|
||||
) : <span className="text-muted-foreground text-sm">—</span>,
|
||||
},
|
||||
{
|
||||
key: 'travelerCount',
|
||||
label: 'Travelers',
|
||||
render: (row: any) => (
|
||||
<span className="font-semibold">{row.travelerCount}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'enquiredAt',
|
||||
label: 'Enquired At',
|
||||
render: (row: any) => (
|
||||
<span className="text-sm">{formatDateTime(row.enquiredAt)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'notes',
|
||||
label: 'Notes',
|
||||
render: (row: any) => (
|
||||
<span className="text-sm text-muted-foreground line-clamp-2 max-w-xs">{row.notes || '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (row: any) => (
|
||||
<select
|
||||
className="input py-1 text-xs"
|
||||
value={row.status}
|
||||
onChange={(e) => statusMutation.mutate({ id: row.id, status: e.target.value })}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); },
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Package Inquiries</h1>
|
||||
<p className="text-muted-foreground">Manage incoming package inquiries</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Package</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.packageId}
|
||||
onChange={(e) => setFilters({ ...filters, packageId: e.target.value })}
|
||||
>
|
||||
<option value="">All Packages</option>
|
||||
{packages.map((p: any) => (
|
||||
<option key={p.id} value={p.id}>{p.name} ({p.code})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No inquiries found"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirm}
|
||||
onClose={() => { setDeleteConfirm(null); setDeleteError(null); }}
|
||||
onConfirm={() => deleteMutation.mutate(deleteConfirm.id)}
|
||||
title="Delete Inquiry"
|
||||
message={`Delete inquiry from ${deleteConfirm?.contactName}? This cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
isDanger
|
||||
isLoading={deleteMutation.isPending}
|
||||
error={deleteError ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,11 @@ const emptyForm = {
|
||||
|
||||
export default function PackagesPage() {
|
||||
const [page] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit' | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@@ -282,6 +287,15 @@ export default function PackagesPage() {
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const allItems: any[] = data?.items || [];
|
||||
const filteredItems = allItems.filter((p) => {
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && !p.code.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (statusFilter && p.status !== statusFilter) return false;
|
||||
if (dateFrom && new Date(p.validFrom).toISOString().split('T')[0] < dateFrom) return false;
|
||||
if (dateTo && new Date(p.validUntil).toISOString().split('T')[0] > dateTo) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -292,13 +306,47 @@ export default function PackagesPage() {
|
||||
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No packages found"
|
||||
/>
|
||||
<div className="card">
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex-1 min-w-48">
|
||||
<input type="text" placeholder="Search by name or code..." className="input"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<select className="input w-44" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">All Status</option>
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="SOLD_OUT">Sold Out</option>
|
||||
<option value="EXPIRED">Expired</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
|
||||
onClick={() => setShowExtraFilters(v => !v)}>
|
||||
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
|
||||
</button>
|
||||
</div>
|
||||
{showExtraFilters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||||
<div>
|
||||
<label className="label">Valid From</label>
|
||||
<input type="date" className="input" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Valid Until</label>
|
||||
<input type="date" className="input" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredItems}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No packages found"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* View Modal */}
|
||||
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">
|
||||
|
||||
@@ -63,8 +63,14 @@ export default function PassengersPage() {
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['passengers', filters],
|
||||
queryFn: () => passengersApi.getAll(filters),
|
||||
queryKey: ['passengers', filters, extraFilters],
|
||||
queryFn: () => passengersApi.getAll({
|
||||
...filters,
|
||||
...(extraFilters.gender && { gender: extraFilters.gender }),
|
||||
...(extraFilters.nationality && { nationality: extraFilters.nationality }),
|
||||
...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }),
|
||||
...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }),
|
||||
}),
|
||||
});
|
||||
|
||||
const PASSENGER_COLS = [
|
||||
|
||||
@@ -78,6 +78,8 @@ export default function TicketsPage() {
|
||||
originStationId: filters.originStationId || undefined,
|
||||
destinationStationId: filters.destinationStationId || undefined,
|
||||
arrivalDate: filters.arrivalDate || undefined,
|
||||
dateFrom: filters.dateFrom || undefined,
|
||||
dateTo: filters.dateTo || undefined,
|
||||
skip: 0,
|
||||
take: 50,
|
||||
}),
|
||||
@@ -483,7 +485,7 @@ export default function TicketsPage() {
|
||||
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
<input
|
||||
@@ -529,20 +531,40 @@ export default function TicketsPage() {
|
||||
onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="USED">Used</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
<div className="flex items-end">
|
||||
<button type="button" className="input w-full px-4 text-sm font-medium text-primary border-primary/40"
|
||||
onClick={() => setShowExtraFilters(v => !v)}>
|
||||
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showExtraFilters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3">
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="USED">Used</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Issued From</label>
|
||||
<input type="date" className="input" value={filters.dateFrom}
|
||||
onChange={(e) => setFilters({ ...filters, dateFrom: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Issued To</label>
|
||||
<input type="date" className="input" value={filters.dateTo}
|
||||
onChange={(e) => setFilters({ ...filters, dateTo: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tickets Table */}
|
||||
|
||||
@@ -60,6 +60,7 @@ const navigationSections = [
|
||||
title: 'Tourism',
|
||||
items: [
|
||||
{ name: 'Packages', href: '/packages', icon: Package },
|
||||
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -397,6 +397,22 @@ export const packagesApi = {
|
||||
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),
|
||||
};
|
||||
|
||||
// Package Inquiries API
|
||||
export const packageInquiriesApi = {
|
||||
getAll: async (params?: any) => {
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params || {}).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
|
||||
) as Record<string, string>;
|
||||
const query = new URLSearchParams(cleanParams).toString();
|
||||
const response = await apiClient.get<any>(`/packages/inquiries${query ? `?${query}` : ''}`);
|
||||
if (response?.data) return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
return Array.isArray(response) ? { items: response } : response;
|
||||
},
|
||||
create: (data: any) => apiClient.post<any>('/packages/inquiries', data),
|
||||
updateStatus: (id: string, status: string) => apiClient.patch<any>(`/packages/inquiries/${id}/status`, { status }),
|
||||
remove: (id: string) => apiClient.delete(`/packages/inquiries/${id}`),
|
||||
};
|
||||
|
||||
// Excess Baggage API
|
||||
export const excessBaggageApi = {
|
||||
logCharge: (data: any) => apiClient.post<any>('/agents/excess-baggage', data),
|
||||
|
||||
Reference in New Issue
Block a user