mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
6
.github/workflows/deploy.yml
vendored
6
.github/workflows/deploy.yml
vendored
@@ -13,7 +13,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
detect-changes:
|
detect-changes:
|
||||||
name: Detect changed services
|
name: Detect changed services
|
||||||
runs-on: self-hosted
|
runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }}
|
||||||
outputs:
|
outputs:
|
||||||
matrix: ${{ steps.filter.outputs.matrix }}
|
matrix: ${{ steps.filter.outputs.matrix }}
|
||||||
steps:
|
steps:
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
|
|
||||||
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
|
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
|
||||||
|
|
||||||
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
|
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
|
||||||
|
|
||||||
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
|
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
|
||||||
if [ -z "$DEPLOYABLE" ]; then
|
if [ -z "$DEPLOYABLE" ]; then
|
||||||
@@ -91,7 +91,7 @@ jobs:
|
|||||||
name: Deploy ${{ matrix.service }}
|
name: Deploy ${{ matrix.service }}
|
||||||
needs: detect-changes
|
needs: detect-changes
|
||||||
if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
|
if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
|
||||||
runs-on: self-hosted
|
runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,3 +24,7 @@ coverage/
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.npmrc
|
.npmrc
|
||||||
|
# emacs cache files
|
||||||
|
*~
|
||||||
|
\#*\#
|
||||||
|
.\#*
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export class CreateFirstMileDto {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value === '' ? undefined : value))
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
vehicleId?: string | null;
|
vehicleId?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ export class FirstMileController {
|
|||||||
return this.firstMileService.findById(id);
|
return this.firstMileService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('accept/:reference')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
|
||||||
|
acceptBooking(@Param('reference') reference: string) {
|
||||||
|
return this.firstMileService.acceptBooking(reference);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Create a first-mile leg' })
|
@ApiOperation({ summary: 'Create a first-mile leg' })
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { FirstMile } from './entities/first-mile.entity';
|
import { FirstMile } from './entities/first-mile.entity';
|
||||||
import { FirstMileController } from './first-mile.controller';
|
import { FirstMileController } from './first-mile.controller';
|
||||||
import { FirstMileRepository } from './first-mile.repository';
|
import { FirstMileRepository } from './first-mile.repository';
|
||||||
import { FirstMileService } from './first-mile.service';
|
import { FirstMileService } from './first-mile.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([FirstMile])],
|
imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule],
|
||||||
controllers: [FirstMileController],
|
controllers: [FirstMileController],
|
||||||
providers: [FirstMileRepository, FirstMileService],
|
providers: [FirstMileRepository, FirstMileService],
|
||||||
exports: [FirstMileRepository, FirstMileService],
|
exports: [FirstMileRepository, FirstMileService],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere } from 'typeorm';
|
import { FindOptionsWhere } from 'typeorm';
|
||||||
|
|
||||||
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||||
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
||||||
@@ -25,7 +26,32 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FirstMileService {
|
export class FirstMileService {
|
||||||
constructor(private readonly firstMileRepository: FirstMileRepository) {}
|
constructor(
|
||||||
|
private readonly firstMileRepository: FirstMileRepository,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a booking by its human-readable reference and confirm it has been
|
||||||
|
* paid before any first-mile work proceeds. Throws if the reference is
|
||||||
|
* unknown or the booking has not reached PAID status.
|
||||||
|
*/
|
||||||
|
async acceptBooking(bookingReference: string): Promise<FirstMile | null> {
|
||||||
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.create({
|
||||||
|
bookingId: booking.id,
|
||||||
|
advancedPayment: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(filter: FirstMileListFilter = {}): Promise<{
|
async findAll(filter: FirstMileListFilter = {}): Promise<{
|
||||||
data: FirstMile[];
|
data: FirstMile[];
|
||||||
@@ -45,7 +71,10 @@ export class FirstMileService {
|
|||||||
|
|
||||||
const [data, total] = await this.firstMileRepository.findAndCount({
|
const [data, total] = await this.firstMileRepository.findAndCount({
|
||||||
where,
|
where,
|
||||||
relations: { booking: true, vehicle: true },
|
relations: {
|
||||||
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
order: { [sortBy]: sortOrder },
|
order: { [sortBy]: sortOrder },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
@@ -64,7 +93,10 @@ export class FirstMileService {
|
|||||||
|
|
||||||
async findById(id: string): Promise<FirstMile> {
|
async findById(id: string): Promise<FirstMile> {
|
||||||
const record = await this.firstMileRepository.findById(id, {
|
const record = await this.firstMileRepository.findById(id, {
|
||||||
relations: { booking: true, vehicle: true },
|
relations: {
|
||||||
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export class CreateLastMileDto {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value === '' ? undefined : value))
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
vehicleId?: string | null;
|
vehicleId?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ export class LastMileController {
|
|||||||
return this.lastMileService.findById(id);
|
return this.lastMileService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('accept/:reference')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||||
|
acceptBooking(@Param('reference') reference: string) {
|
||||||
|
return this.lastMileService.acceptBooking(reference);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Create a last-mile leg' })
|
@ApiOperation({ summary: 'Create a last-mile leg' })
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { LastMile } from './entities/last-mile.entity';
|
import { LastMile } from './entities/last-mile.entity';
|
||||||
import { LastMileController } from './last-mile.controller';
|
import { LastMileController } from './last-mile.controller';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
import { LastMileService } from './last-mile.service';
|
import { LastMileService } from './last-mile.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([LastMile])],
|
imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule],
|
||||||
controllers: [LastMileController],
|
controllers: [LastMileController],
|
||||||
providers: [LastMileRepository, LastMileService],
|
providers: [LastMileRepository, LastMileService],
|
||||||
exports: [LastMileRepository, LastMileService],
|
exports: [LastMileRepository, LastMileService],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere } from 'typeorm';
|
import { FindOptionsWhere } from 'typeorm';
|
||||||
|
|
||||||
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||||
@@ -25,7 +26,29 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LastMileService {
|
export class LastMileService {
|
||||||
constructor(private readonly lastMileRepository: LastMileRepository) {}
|
constructor(
|
||||||
|
private readonly lastMileRepository: LastMileRepository,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async acceptBooking(bookingReference: string): Promise<LastMile> {
|
||||||
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking ${bookingReference} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.create({
|
||||||
|
bookingId: booking.id,
|
||||||
|
advancedPayment: booking.totalAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(filter: LastMileListFilter = {}): Promise<{
|
async findAll(filter: LastMileListFilter = {}): Promise<{
|
||||||
data: LastMile[];
|
data: LastMile[];
|
||||||
@@ -45,7 +68,10 @@ export class LastMileService {
|
|||||||
|
|
||||||
const [data, total] = await this.lastMileRepository.findAndCount({
|
const [data, total] = await this.lastMileRepository.findAndCount({
|
||||||
where,
|
where,
|
||||||
relations: { booking: true, vehicle: true },
|
relations: {
|
||||||
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
order: { [sortBy]: sortOrder },
|
order: { [sortBy]: sortOrder },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
@@ -64,7 +90,10 @@ export class LastMileService {
|
|||||||
|
|
||||||
async findById(id: string): Promise<LastMile> {
|
async findById(id: string): Promise<LastMile> {
|
||||||
const record = await this.lastMileRepository.findById(id, {
|
const record = await this.lastMileRepository.findById(id, {
|
||||||
relations: { booking: true, vehicle: true },
|
relations: {
|
||||||
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ export const QUERY_KEYS = {
|
|||||||
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
|
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
FIRST_MILE: {
|
||||||
|
ROOT: ["first-mile"] as const,
|
||||||
|
list: (filter?: Record<string, unknown>) => ["first-mile", "list", filter ?? {}] as const,
|
||||||
|
byId: (id: string) => ["first-mile", "detail", id] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
LAST_MILE: {
|
||||||
|
ROOT: ["last-mile"] as const,
|
||||||
|
list: (filter?: Record<string, unknown>) => ["last-mile", "list", filter ?? {}] as const,
|
||||||
|
byId: (id: string) => ["last-mile", "detail", id] as const,
|
||||||
|
},
|
||||||
|
|
||||||
RULE_ENGINE: {
|
RULE_ENGINE: {
|
||||||
ROOT: ["rule-engine"] as const,
|
ROOT: ["rule-engine"] as const,
|
||||||
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
|
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
|
||||||
|
|||||||
@@ -362,6 +362,18 @@ export const URL_CONSTANTS = {
|
|||||||
BY_ID: (id: string) => `/vehicles/${id}`,
|
BY_ID: (id: string) => `/vehicles/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
FIRST_MILE: {
|
||||||
|
BASE: '/first-mile',
|
||||||
|
BY_ID: (id: string) => `/first-mile/${id}`,
|
||||||
|
ACCEPT: (reference: string) => `/first-mile/accept/${reference}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
LAST_MILE: {
|
||||||
|
BASE: '/last-mile',
|
||||||
|
BY_ID: (id: string) => `/last-mile/${id}`,
|
||||||
|
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
||||||
|
},
|
||||||
|
|
||||||
DRIVERS: {
|
DRIVERS: {
|
||||||
BASE: '/drivers',
|
BASE: '/drivers',
|
||||||
BY_ID: (id: string) => `/drivers/${id}`,
|
BY_ID: (id: string) => `/drivers/${id}`,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Truck,
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
@@ -28,29 +29,15 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
type LastMileStatus = "UNASSIGNED" | "ASSIGNED";
|
LAST_MILE_STATUSES,
|
||||||
type DeliveryStatus = "PAYMENT_PENDING" | "READY_TO_TRANSIT" | "DELIVERED";
|
type LastMileApiStatus,
|
||||||
|
type LastMileRecord,
|
||||||
interface LastMileJob {
|
lastMileService,
|
||||||
id: string;
|
} from "@/services/last-mile.service";
|
||||||
bookingRef: string;
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
customer: string;
|
|
||||||
destination: string;
|
|
||||||
cargo: string;
|
|
||||||
status: LastMileStatus;
|
|
||||||
deliveryStatus: DeliveryStatus;
|
|
||||||
assignedVehicle: string | null;
|
|
||||||
// Booking info shown in the Assign / View Detail modals.
|
|
||||||
serviceType: string;
|
|
||||||
weight: string;
|
|
||||||
price: number;
|
|
||||||
originYard: string;
|
|
||||||
contactName: string;
|
|
||||||
contactPhone: string;
|
|
||||||
requestedDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatPrice = (amount: number) =>
|
const formatPrice = (amount: number) =>
|
||||||
`ETB ${amount.toLocaleString("en-US", {
|
`ETB ${amount.toLocaleString("en-US", {
|
||||||
@@ -58,158 +45,108 @@ const formatPrice = (amount: number) =>
|
|||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
})}`;
|
})}`;
|
||||||
|
|
||||||
const DELIVERY_STATUS_META: Record<
|
const STATUS_META: Record<LastMileApiStatus, { label: string; color: string }> = {
|
||||||
DeliveryStatus,
|
|
||||||
{ label: string; color: string }
|
|
||||||
> = {
|
|
||||||
PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" },
|
PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" },
|
||||||
READY_TO_TRANSIT: { label: "Ready to Transit", color: "blue" },
|
READY_TO_TRANSIT: { label: "Ready to Transit", color: "blue" },
|
||||||
|
IN_TRANSIT: { label: "In Transit", color: "indigo" },
|
||||||
DELIVERED: { label: "Delivered", color: "green" },
|
DELIVERED: { label: "Delivered", color: "green" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Forward-only lifecycle: Payment Pending → Ready to Transit → Delivered.
|
const NEXT_STATUS: Partial<Record<LastMileApiStatus, LastMileApiStatus>> = {
|
||||||
const NEXT_DELIVERY_STATUS: Partial<Record<DeliveryStatus, DeliveryStatus>> = {
|
|
||||||
PAYMENT_PENDING: "READY_TO_TRANSIT",
|
PAYMENT_PENDING: "READY_TO_TRANSIT",
|
||||||
READY_TO_TRANSIT: "DELIVERED",
|
READY_TO_TRANSIT: "IN_TRANSIT",
|
||||||
|
IN_TRANSIT: "DELIVERED",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Single filter covering both the delivery lifecycle and assignment state.
|
type AssignmentStatus = "ASSIGNED" | "UNASSIGNED";
|
||||||
type StatusFilter =
|
type StatusFilter = "ALL" | LastMileApiStatus | AssignmentStatus;
|
||||||
| "ALL"
|
|
||||||
| DeliveryStatus
|
|
||||||
| LastMileStatus;
|
|
||||||
|
|
||||||
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||||
{ value: "ALL", label: "All statuses" },
|
{ value: "ALL", label: "All" },
|
||||||
{ value: "PAYMENT_PENDING", label: "Payment Pending" },
|
...LAST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
||||||
{ value: "READY_TO_TRANSIT", label: "Ready to Transit" },
|
|
||||||
{ value: "DELIVERED", label: "Delivered" },
|
|
||||||
{ value: "ASSIGNED", label: "Assigned" },
|
{ value: "ASSIGNED", label: "Assigned" },
|
||||||
{ value: "UNASSIGNED", label: "Unassigned" },
|
{ value: "UNASSIGNED", label: "Unassigned" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Placeholder data — replace with a real last-mile service once the API exists.
|
const vehicleLabel = (record: LastMileRecord) => {
|
||||||
const PLACEHOLDER_JOBS: LastMileJob[] = [
|
if (!record.vehicle) return null;
|
||||||
{
|
const v = record.vehicle;
|
||||||
id: "1",
|
return `${v.manufacturer} ${v.model} (${v.plateNumber})`;
|
||||||
bookingRef: "BK-10241",
|
};
|
||||||
customer: "Awash Trading PLC",
|
|
||||||
destination: "Bole Sub-city, Addis Ababa",
|
|
||||||
cargo: "20ft container · Electronics",
|
|
||||||
status: "UNASSIGNED",
|
|
||||||
deliveryStatus: "PAYMENT_PENDING",
|
|
||||||
assignedVehicle: null,
|
|
||||||
serviceType: "Door-to-door (Last Mile)",
|
|
||||||
weight: "12.4 t",
|
|
||||||
price: 4500,
|
|
||||||
originYard: "Indode Dry Port",
|
|
||||||
contactName: "Selam Bekele",
|
|
||||||
contactPhone: "+251 911 234 567",
|
|
||||||
requestedDate: "2026-06-22",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
bookingRef: "BK-10238",
|
|
||||||
customer: "Dire Logistics",
|
|
||||||
destination: "Industry Zone, Dire Dawa",
|
|
||||||
cargo: "Bulk · 18t Cement",
|
|
||||||
status: "ASSIGNED",
|
|
||||||
deliveryStatus: "READY_TO_TRANSIT",
|
|
||||||
assignedVehicle: "Isuzu FVR (3-AA-45821)",
|
|
||||||
serviceType: "Terminal-to-door (Last Mile)",
|
|
||||||
weight: "18.0 t",
|
|
||||||
price: 3200,
|
|
||||||
originYard: "Dire Dawa Terminal",
|
|
||||||
contactName: "Yonas Tadesse",
|
|
||||||
contactPhone: "+251 912 887 010",
|
|
||||||
requestedDate: "2026-06-21",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
bookingRef: "BK-10233",
|
|
||||||
customer: "Horizon Imports",
|
|
||||||
destination: "Kality Terminal, Addis Ababa",
|
|
||||||
cargo: "40ft container · Machinery",
|
|
||||||
status: "UNASSIGNED",
|
|
||||||
deliveryStatus: "DELIVERED",
|
|
||||||
assignedVehicle: null,
|
|
||||||
serviceType: "Door-to-door (Last Mile)",
|
|
||||||
weight: "24.7 t",
|
|
||||||
price: 6800,
|
|
||||||
originYard: "Mojo Dry Port",
|
|
||||||
contactName: "Hanna Girma",
|
|
||||||
contactPhone: "+251 913 445 221",
|
|
||||||
requestedDate: "2026-06-23",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Placeholder vehicle options — replace with the vehicles service.
|
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
|
||||||
const VEHICLE_OPTIONS = [
|
|
||||||
{ value: "isuzu-fvr-45821", label: "Isuzu FVR (3-AA-45821)" },
|
const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
|
||||||
{ value: "sino-howo-12044", label: "Sinotruk Howo (3-AA-12044)" },
|
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
||||||
{ value: "mercedes-actros-90113", label: "Mercedes Actros (3-AA-90113)" },
|
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
||||||
];
|
const cargoDesc = (r: LastMileRecord) => {
|
||||||
|
const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
|
||||||
|
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||||
|
return parts.join(" · ") || "—";
|
||||||
|
};
|
||||||
|
const priceAmount = (r: LastMileRecord) =>
|
||||||
|
r.booking?.totalAmount ?? r.advancedPayment;
|
||||||
|
const originYardName = (r: LastMileRecord) =>
|
||||||
|
r.booking?.originYard?.name ?? "—";
|
||||||
|
const contactPersonName = (r: LastMileRecord) =>
|
||||||
|
r.booking?.company?.contactPersonName ?? "—";
|
||||||
|
const contactPhone = (r: LastMileRecord) =>
|
||||||
|
r.booking?.company?.contactPersonPhone ?? r.booking?.company?.phone ?? "—";
|
||||||
|
const requestedDate = (r: LastMileRecord) => {
|
||||||
|
const d = r.booking?.scheduledDate;
|
||||||
|
return d ? new Date(d).toISOString().slice(0, 10) : "—";
|
||||||
|
};
|
||||||
|
const serviceTypeName = (r: LastMileRecord) =>
|
||||||
|
r.booking?.serviceType?.name ?? "—";
|
||||||
|
|
||||||
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">{value}</Text>
|
<Text size="sm">{value}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
const BookingInfo = ({ job }: { job: LastMileJob }) => (
|
const BookingInfo = ({ record }: { record: LastMileRecord }) => (
|
||||||
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
|
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={600}>{job.bookingRef}</Text>
|
<Text fw={600}>{bookingRef(record)}</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Badge
|
<Badge color={STATUS_META[record.status].color} variant="light" size="sm">
|
||||||
color={DELIVERY_STATUS_META[job.deliveryStatus].color}
|
{STATUS_META[record.status].label}
|
||||||
variant="light"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{DELIVERY_STATUS_META[job.deliveryStatus].label}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge color={isAssigned(record) ? "green" : "orange"} variant="light" size="sm">
|
||||||
color={job.status === "ASSIGNED" ? "green" : "orange"}
|
{isAssigned(record) ? "Assigned" : "Unassigned"}
|
||||||
variant="light"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{job.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<SimpleGrid cols={2} spacing="sm">
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
<InfoRow label="Customer" value={job.customer} />
|
<InfoRow label="Customer" value={customerName(record)} />
|
||||||
<InfoRow label="Service type" value={job.serviceType} />
|
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||||
<InfoRow label="Origin yard" value={job.originYard} />
|
<InfoRow label="Origin yard" value={originYardName(record)} />
|
||||||
<InfoRow label="Destination" value={job.destination} />
|
<InfoRow label="Destination" value={deliveryLocation(record)} />
|
||||||
<InfoRow label="Cargo" value={job.cargo} />
|
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||||
<InfoRow label="Weight" value={job.weight} />
|
<InfoRow label="Price" value={formatPrice(priceAmount(record))} />
|
||||||
<InfoRow label="Price" value={formatPrice(job.price)} />
|
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||||
<InfoRow label="Contact" value={job.contactName} />
|
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||||
<InfoRow label="Phone" value={job.contactPhone} />
|
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||||
<InfoRow label="Requested date" value={job.requestedDate} />
|
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||||
<InfoRow label="Assigned vehicle" value={job.assignedVehicle ?? "—"} />
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
const tripSlipRows = (job: LastMileJob): [string, string][] => [
|
const tripSlipRows = (record: LastMileRecord): [string, string][] => [
|
||||||
["Customer", job.customer],
|
["Customer", customerName(record)],
|
||||||
["Service", job.serviceType],
|
["Service", serviceTypeName(record)],
|
||||||
["Origin yard", job.originYard],
|
["Origin yard", originYardName(record)],
|
||||||
["Destination", job.destination],
|
["Destination", deliveryLocation(record)],
|
||||||
["Cargo", job.cargo],
|
["Cargo", cargoDesc(record)],
|
||||||
["Weight", job.weight],
|
["Price", formatPrice(priceAmount(record))],
|
||||||
["Price", formatPrice(job.price)],
|
["Vehicle", vehicleLabel(record) ?? "Unassigned"],
|
||||||
["Vehicle", job.assignedVehicle ?? "Unassigned"],
|
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
|
||||||
["Contact", `${job.contactName} · ${job.contactPhone}`],
|
["Requested date", requestedDate(record)],
|
||||||
["Requested date", job.requestedDate],
|
["Status", STATUS_META[record.status].label],
|
||||||
["Delivery status", DELIVERY_STATUS_META[job.deliveryStatus].label],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const SampleStamp = () => (
|
const SampleStamp = () => (
|
||||||
@@ -241,15 +178,9 @@ const SampleStamp = () => (
|
|||||||
lineHeight: 1.1,
|
lineHeight: 1.1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="9px" fw={700} style={{ letterSpacing: 1 }}>
|
<Text size="9px" fw={700} style={{ letterSpacing: 1 }}>EDR FREIGHT</Text>
|
||||||
EDR FREIGHT
|
<Text size="sm" fw={800}>APPROVED</Text>
|
||||||
</Text>
|
<Text size="8px" fw={600}>OPERATIONS</Text>
|
||||||
<Text size="sm" fw={800}>
|
|
||||||
APPROVED
|
|
||||||
</Text>
|
|
||||||
<Text size="8px" fw={600}>
|
|
||||||
OPERATIONS
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -257,56 +188,36 @@ const SampleStamp = () => (
|
|||||||
|
|
||||||
const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => (
|
const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => (
|
||||||
<Stack gap="sm" style={{ flex: 1, position: "relative", minHeight: stamp ? 130 : undefined }}>
|
<Stack gap="sm" style={{ flex: 1, position: "relative", minHeight: stamp ? 130 : undefined }}>
|
||||||
<Text fw={600} size="sm">
|
<Text fw={600} size="sm">{title}</Text>
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs" align="flex-end">
|
<Group gap="xs" align="flex-end">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">Name:</Text>
|
||||||
Name:
|
|
||||||
</Text>
|
|
||||||
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
|
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs" align="flex-end">
|
<Group gap="xs" align="flex-end">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">Signature:</Text>
|
||||||
Signature:
|
|
||||||
</Text>
|
|
||||||
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
|
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
|
||||||
</Group>
|
</Group>
|
||||||
{stamp && (
|
{stamp && (
|
||||||
<Box
|
<Box style={{ position: "absolute", right: 4, top: 22, opacity: 0.85, pointerEvents: "none" }}>
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: 4,
|
|
||||||
top: 22,
|
|
||||||
opacity: 0.85,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stamp}
|
{stamp}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
const TripSlipDocument = ({ job }: { job: LastMileJob }) => (
|
const TripSlipDocument = ({ record }: { record: LastMileRecord }) => (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Stack gap={2} align="center">
|
<Stack gap={2} align="center">
|
||||||
<Text fw={700}>EDR Freight</Text>
|
<Text fw={700}>EDR Freight</Text>
|
||||||
<Text size="sm" c="dimmed" tt="uppercase" fw={600}>
|
<Text size="sm" c="dimmed" tt="uppercase" fw={600}>Last Mile Trip Slip</Text>
|
||||||
Last Mile Trip Slip
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>{bookingRef(record)}</Text>
|
||||||
{job.bookingRef}
|
<Text size="sm" c="dimmed">{requestedDate(record)}</Text>
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{job.requestedDate}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
<SimpleGrid cols={2} spacing="xs">
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
{tripSlipRows(job).map(([label, value]) => (
|
{tripSlipRows(record).map(([label, value]) => (
|
||||||
<InfoRow key={label} label={label} value={value} />
|
<InfoRow key={label} label={label} value={value} />
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -318,32 +229,22 @@ const TripSlipDocument = ({ job }: { job: LastMileJob }) => (
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
const escapeHtml = (value: string) =>
|
const escapeHtml = (v: string) =>
|
||||||
value
|
v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
|
|
||||||
const buildTripSlipHtml = (job: LastMileJob) => {
|
const buildTripSlipHtml = (record: LastMileRecord) => {
|
||||||
const rows = tripSlipRows(job)
|
const rows = tripSlipRows(record)
|
||||||
.map(
|
.map(([l, v]) => `<tr><td class="lbl">${escapeHtml(l)}</td><td>${escapeHtml(v)}</td></tr>`)
|
||||||
([label, value]) =>
|
|
||||||
`<tr><td class="lbl">${escapeHtml(label)}</td><td>${escapeHtml(value)}</td></tr>`,
|
|
||||||
)
|
|
||||||
.join("");
|
.join("");
|
||||||
const signature = (title: string, withStamp: boolean) => `
|
const sig = (title: string, withStamp: boolean) => `
|
||||||
<div class="sign-col">
|
<div class="sign-col">
|
||||||
<div class="sign-title">${title}</div>
|
<div class="sign-title">${title}</div>
|
||||||
<div class="sign-field"><span>Name:</span><span class="line"></span></div>
|
<div class="sign-field"><span>Name:</span><span class="line"></span></div>
|
||||||
<div class="sign-field"><span>Signature:</span><span class="line"></span></div>
|
<div class="sign-field"><span>Signature:</span><span class="line"></span></div>
|
||||||
${
|
${withStamp ? '<div class="stamp"><div class="ring"><div class="ring-inner"><span>EDR FREIGHT</span><strong>APPROVED</strong><span>OPERATIONS</span></div></div></div>' : ""}
|
||||||
withStamp
|
|
||||||
? '<div class="stamp"><div class="ring"><div class="ring-inner"><span>EDR FREIGHT</span><strong>APPROVED</strong><span>OPERATIONS</span></div></div></div>'
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
return `<!doctype html><html><head><meta charset="utf-8" />
|
return `<!doctype html><html><head><meta charset="utf-8" />
|
||||||
<title>Trip Slip ${escapeHtml(job.bookingRef)}</title>
|
<title>Trip Slip ${escapeHtml(bookingRef(record))}</title>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { font-family: Arial, Helvetica, sans-serif; color: #111; margin: 32px; }
|
body { font-family: Arial, Helvetica, sans-serif; color: #111; margin: 32px; }
|
||||||
@@ -368,18 +269,18 @@ const buildTripSlipHtml = (job: LastMileJob) => {
|
|||||||
</style></head>
|
</style></head>
|
||||||
<body onload="window.print()">
|
<body onload="window.print()">
|
||||||
<div class="head"><h1>EDR Freight</h1><p>Last Mile Trip Slip</p></div>
|
<div class="head"><h1>EDR Freight</h1><p>Last Mile Trip Slip</p></div>
|
||||||
<div class="meta"><span>${escapeHtml(job.bookingRef)}</span><span>${escapeHtml(job.requestedDate)}</span></div>
|
<div class="meta"><span>${escapeHtml(bookingRef(record))}</span><span>${escapeHtml(requestedDate(record))}</span></div>
|
||||||
<table>${rows}</table>
|
<table>${rows}</table>
|
||||||
<div class="ack">Acknowledgement</div>
|
<div class="ack">Acknowledgement</div>
|
||||||
<div class="signs">${signature("Driver", false)}${signature("Operator", true)}</div>
|
<div class="signs">${sig("Driver", false)}${sig("Operator", true)}</div>
|
||||||
</body></html>`;
|
</body></html>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LastMilePage = () => {
|
const LastMilePage = () => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
const [jobs, setJobs] = useState<LastMileJob[]>(PLACEHOLDER_JOBS);
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
|
||||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
@@ -388,13 +289,51 @@ const LastMilePage = () => {
|
|||||||
const [bulkMode, setBulkMode] = useState(false);
|
const [bulkMode, setBulkMode] = useState(false);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [tripSlipOpen, setTripSlipOpen] = useState(false);
|
const [tripSlipOpen, setTripSlipOpen] = useState(false);
|
||||||
const [tripSlipJob, setTripSlipJob] = useState<LastMileJob | null>(null);
|
const [tripSlipRecord, setTripSlipRecord] = useState<LastMileRecord | null>(null);
|
||||||
const [activeJobId, setActiveJobId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [vehicleValue, setVehicleValue] = useState<string | null>(null);
|
const [vehicleValue, setVehicleValue] = useState<string | null>(null);
|
||||||
|
|
||||||
const activeJob = useMemo(
|
const { data: listData, isLoading } = useQuery({
|
||||||
() => jobs.find((job) => job.id === activeJobId) ?? null,
|
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
||||||
[jobs, activeJobId],
|
queryFn: async () => {
|
||||||
|
const res = await lastMileService.list();
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: vehiclesData } = useQuery({
|
||||||
|
queryKey: ["vehicles", "list"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await vehiclesService.getAll({ status: "ACTIVE" });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = listData?.data ?? [];
|
||||||
|
|
||||||
|
const vehicleOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({
|
||||||
|
value: v.id,
|
||||||
|
label: `${v.manufacturer} ${v.model} (${v.plateNumber})`,
|
||||||
|
})),
|
||||||
|
[vehiclesData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: { status?: LastMileApiStatus; vehicleId?: string | null } }) =>
|
||||||
|
lastMileService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: "Update failed", variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeRecord = useMemo(
|
||||||
|
() => records.find((r) => r.id === activeId) ?? null,
|
||||||
|
[records, activeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedIds = useMemo(
|
const selectedIds = useMemo(
|
||||||
@@ -402,160 +341,129 @@ const LastMilePage = () => {
|
|||||||
[rowSelection],
|
[rowSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const matchesStatusFilter = (job: LastMileJob) => {
|
const matchesFilter = (r: LastMileRecord) => {
|
||||||
switch (statusFilter) {
|
switch (statusFilter) {
|
||||||
case "ALL":
|
case "ALL": return true;
|
||||||
return true;
|
case "ASSIGNED": return isAssigned(r);
|
||||||
case "ASSIGNED":
|
case "UNASSIGNED": return !isAssigned(r);
|
||||||
case "UNASSIGNED":
|
default: return r.status === statusFilter;
|
||||||
return job.status === statusFilter;
|
|
||||||
default:
|
|
||||||
return job.deliveryStatus === statusFilter;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
const counts: Record<StatusFilter, number> = {
|
const counts: Record<StatusFilter, number> = {
|
||||||
ALL: jobs.length,
|
ALL: records.length,
|
||||||
PAYMENT_PENDING: 0,
|
PAYMENT_PENDING: 0,
|
||||||
READY_TO_TRANSIT: 0,
|
READY_TO_TRANSIT: 0,
|
||||||
|
IN_TRANSIT: 0,
|
||||||
DELIVERED: 0,
|
DELIVERED: 0,
|
||||||
ASSIGNED: 0,
|
ASSIGNED: 0,
|
||||||
UNASSIGNED: 0,
|
UNASSIGNED: 0,
|
||||||
};
|
};
|
||||||
for (const job of jobs) {
|
for (const r of records) {
|
||||||
counts[job.deliveryStatus] += 1;
|
counts[r.status] = (counts[r.status] ?? 0) + 1;
|
||||||
counts[job.status] += 1;
|
if (isAssigned(r)) counts.ASSIGNED += 1;
|
||||||
|
else counts.UNASSIGNED += 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [jobs]);
|
}, [records]);
|
||||||
|
|
||||||
const filteredJobs = useMemo(() => {
|
const filteredRecords = useMemo(() => {
|
||||||
const term = search.trim().toLowerCase();
|
const term = search.trim().toLowerCase();
|
||||||
return jobs.filter((job) => {
|
return records.filter((r) => {
|
||||||
if (!matchesStatusFilter(job)) return false;
|
if (!matchesFilter(r)) return false;
|
||||||
if (!term) return true;
|
if (!term) return true;
|
||||||
return [job.bookingRef, job.customer, job.destination, job.cargo]
|
return [bookingRef(r), customerName(r), deliveryLocation(r), cargoDesc(r)]
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(term);
|
.includes(term);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [jobs, search, statusFilter]);
|
}, [records, search, statusFilter]);
|
||||||
|
|
||||||
const pageCount = Math.max(1, Math.ceil(filteredJobs.length / pagination.pageSize));
|
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
|
||||||
const pagedJobs = useMemo(() => {
|
const pagedRecords = useMemo(() => {
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
const start = pagination.pageIndex * pagination.pageSize;
|
||||||
return filteredJobs.slice(start, start + pagination.pageSize);
|
return filteredRecords.slice(start, start + pagination.pageSize);
|
||||||
}, [filteredJobs, pagination.pageIndex, pagination.pageSize]);
|
}, [filteredRecords, pagination]);
|
||||||
|
|
||||||
const openAssign = (jobId: string | null) => {
|
const openAssign = (id: string | null) => {
|
||||||
const resolved = jobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id ?? null;
|
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveJobId(resolved);
|
setActiveId(resolved);
|
||||||
setVehicleValue(null);
|
setVehicleValue(null);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openBulkAssign = () => {
|
const openBulkAssign = () => {
|
||||||
setBulkMode(true);
|
setBulkMode(true);
|
||||||
setActiveJobId(null);
|
setActiveId(null);
|
||||||
setVehicleValue(null);
|
setVehicleValue(null);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDetail = (jobId: string) => {
|
|
||||||
setActiveJobId(jobId);
|
|
||||||
setDetailOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeAssign = () => {
|
const closeAssign = () => {
|
||||||
setAssignOpen(false);
|
setAssignOpen(false);
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveJobId(null);
|
setActiveId(null);
|
||||||
setVehicleValue(null);
|
setVehicleValue(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDetail = () => {
|
|
||||||
setDetailOpen(false);
|
|
||||||
setActiveJobId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAssign = () => {
|
const handleAssign = () => {
|
||||||
if (!vehicleValue) {
|
if (!vehicleValue) {
|
||||||
toast({
|
toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" });
|
||||||
title: "Select a vehicle",
|
|
||||||
description: "Choose a vehicle to assign to this delivery.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vehicleLabel =
|
|
||||||
VEHICLE_OPTIONS.find((option) => option.value === vehicleValue)?.label ?? vehicleValue;
|
|
||||||
|
|
||||||
const targetIds = bulkMode
|
const targetIds = bulkMode
|
||||||
? selectedIds
|
? selectedIds
|
||||||
: [activeJobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id].filter(
|
: [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id));
|
||||||
(id): id is string => Boolean(id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!targetIds.length) return;
|
if (!targetIds.length) return;
|
||||||
|
|
||||||
const targetSet = new Set(targetIds);
|
const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue;
|
||||||
setJobs((current) =>
|
|
||||||
current.map((job) =>
|
|
||||||
targetSet.has(job.id)
|
|
||||||
? { ...job, status: "ASSIGNED", assignedVehicle: vehicleLabel }
|
|
||||||
: job,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } })))
|
||||||
|
.then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: "Vehicle assigned",
|
title: "Vehicle assigned",
|
||||||
description: bulkMode
|
description: bulkMode ? `${targetIds.length} deliveries → ${selectedLabel}` : selectedLabel,
|
||||||
? `${targetIds.length} deliveries → ${vehicleLabel}`
|
|
||||||
: vehicleLabel,
|
|
||||||
});
|
});
|
||||||
if (bulkMode) setRowSelection({});
|
if (bulkMode) setRowSelection({});
|
||||||
closeAssign();
|
closeAssign();
|
||||||
|
})
|
||||||
|
.catch(() => void 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdvanceStatus = (job: LastMileJob) => {
|
const handleAdvanceStatus = (record: LastMileRecord) => {
|
||||||
const next = NEXT_DELIVERY_STATUS[job.deliveryStatus];
|
const next = NEXT_STATUS[record.status];
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
setJobs((current) =>
|
updateMutation.mutate(
|
||||||
current.map((item) =>
|
{ id: record.id, data: { status: next } },
|
||||||
item.id === job.id ? { ...item, deliveryStatus: next } : item,
|
{
|
||||||
),
|
onSuccess: () =>
|
||||||
|
toast({ title: "Status updated", description: `${bookingRef(record)} → ${STATUS_META[next].label}` }),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
toast({
|
|
||||||
title: "Status updated",
|
|
||||||
description: `${job.bookingRef} → ${DELIVERY_STATUS_META[next].label}`,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrintTripSlip = (job: LastMileJob) => {
|
const handlePrintTripSlip = (record: LastMileRecord) => {
|
||||||
setTripSlipJob(job);
|
setTripSlipRecord(record);
|
||||||
setTripSlipOpen(true);
|
setTripSlipOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const printTripSlip = () => {
|
const printTripSlip = () => {
|
||||||
if (!tripSlipJob) return;
|
if (!tripSlipRecord) return;
|
||||||
const win = window.open("", "_blank", "width=820,height=920");
|
const win = window.open("", "_blank", "width=820,height=920");
|
||||||
if (!win) {
|
if (!win) {
|
||||||
toast({
|
toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" });
|
||||||
title: "Pop-up blocked",
|
|
||||||
description: "Allow pop-ups to print the trip slip.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
win.document.write(buildTripSlipHtml(tripSlipJob));
|
win.document.write(buildTripSlipHtml(tripSlipRecord));
|
||||||
win.document.close();
|
win.document.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo((): ColumnDef<LastMileJob>[] => {
|
const columns = useMemo((): ColumnDef<LastMileRecord>[] => {
|
||||||
const headerClassName = ruleEngineTable.headerCell;
|
const headerClassName = ruleEngineTable.headerCell;
|
||||||
const cellClassName = ruleEngineTable.bodyCell;
|
const cellClassName = ruleEngineTable.bodyCell;
|
||||||
return [
|
return [
|
||||||
@@ -567,9 +475,7 @@ const LastMilePage = () => {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label="Select all"
|
aria-label="Select all"
|
||||||
checked={table.getIsAllPageRowsSelected()}
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
indeterminate={
|
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
|
||||||
table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
|
|
||||||
}
|
|
||||||
onChange={(e) => table.toggleAllPageRowsSelected(e.currentTarget.checked)}
|
onChange={(e) => table.toggleAllPageRowsSelected(e.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -586,67 +492,54 @@ const LastMilePage = () => {
|
|||||||
id: "bookingRef",
|
id: "bookingRef",
|
||||||
header: "Booking",
|
header: "Booking",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <Text size="sm" fw={600}>{bookingRef(row.original)}</Text>,
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{row.original.bookingRef}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "customer",
|
id: "customer",
|
||||||
header: "Customer",
|
header: "Customer",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => row.original.customer,
|
cell: ({ row }) => customerName(row.original),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "destination",
|
id: "destination",
|
||||||
header: "Destination",
|
header: "Destination",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => row.original.destination,
|
cell: ({ row }) => deliveryLocation(row.original),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cargo",
|
id: "cargo",
|
||||||
header: "Cargo",
|
header: "Cargo",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => row.original.cargo,
|
cell: ({ row }) => cargoDesc(row.original),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "price",
|
id: "price",
|
||||||
header: "Price",
|
header: "Price",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => formatPrice(row.original.price),
|
cell: ({ row }) => formatPrice(priceAmount(row.original)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "vehicle",
|
id: "vehicle",
|
||||||
header: "Vehicle",
|
header: "Vehicle",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed">—</Text>,
|
||||||
row.original.assignedVehicle ?? <Text c="dimmed">—</Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "deliveryStatus",
|
|
||||||
header: "Status",
|
|
||||||
meta: { headerClassName, cellClassName },
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const meta = DELIVERY_STATUS_META[row.original.deliveryStatus];
|
|
||||||
return (
|
|
||||||
<Badge color={meta.color} variant="light" size="sm">
|
|
||||||
{meta.label}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const meta = STATUS_META[row.original.status];
|
||||||
|
return <Badge color={meta.color} variant="light" size="sm">{meta.label}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "assignment",
|
||||||
header: "Assignment",
|
header: "Assignment",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge
|
<Badge color={isAssigned(row.original) ? "green" : "orange"} variant="light" size="sm">
|
||||||
color={row.original.status === "ASSIGNED" ? "green" : "orange"}
|
{isAssigned(row.original) ? "Assigned" : "Unassigned"}
|
||||||
variant="light"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{row.original.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -655,11 +548,9 @@ const LastMilePage = () => {
|
|||||||
header: "Actions",
|
header: "Actions",
|
||||||
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const isAssigned = row.original.status === "ASSIGNED";
|
const assigned = isAssigned(row.original);
|
||||||
const nextStatus = NEXT_DELIVERY_STATUS[row.original.deliveryStatus];
|
const nextStatus = NEXT_STATUS[row.original.status];
|
||||||
const canPrintTripSlip =
|
const canPrint = row.original.status !== "PAYMENT_PENDING";
|
||||||
row.original.deliveryStatus === "READY_TO_TRANSIT" ||
|
|
||||||
row.original.deliveryStatus === "DELIVERED";
|
|
||||||
return (
|
return (
|
||||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
<Menu position="bottom-end" width={200} withinPortal>
|
<Menu position="bottom-end" width={200} withinPortal>
|
||||||
@@ -674,21 +565,19 @@ const LastMilePage = () => {
|
|||||||
disabled={!nextStatus}
|
disabled={!nextStatus}
|
||||||
onClick={() => handleAdvanceStatus(row.original)}
|
onClick={() => handleAdvanceStatus(row.original)}
|
||||||
>
|
>
|
||||||
{nextStatus
|
{nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label}
|
||||||
? `Mark ${DELIVERY_STATUS_META[nextStatus].label}`
|
|
||||||
: "Delivered"}
|
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Truck size={15} />}
|
leftSection={<Truck size={15} />}
|
||||||
disabled={isAssigned}
|
disabled={assigned}
|
||||||
onClick={() => openAssign(row.original.id)}
|
onClick={() => openAssign(row.original.id)}
|
||||||
>
|
>
|
||||||
Assign
|
Assign
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<RefreshCw size={15} />}
|
leftSection={<RefreshCw size={15} />}
|
||||||
disabled={!isAssigned}
|
disabled={!assigned}
|
||||||
onClick={() => openAssign(row.original.id)}
|
onClick={() => openAssign(row.original.id)}
|
||||||
>
|
>
|
||||||
Reassign
|
Reassign
|
||||||
@@ -696,11 +585,11 @@ const LastMilePage = () => {
|
|||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Eye size={15} />}
|
leftSection={<Eye size={15} />}
|
||||||
onClick={() => openDetail(row.original.id)}
|
onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }}
|
||||||
>
|
>
|
||||||
View detail
|
View detail
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{canPrintTripSlip && (
|
{canPrint && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Printer size={15} />}
|
leftSection={<Printer size={15} />}
|
||||||
onClick={() => handlePrintTripSlip(row.original)}
|
onClick={() => handlePrintTripSlip(row.original)}
|
||||||
@@ -715,18 +604,12 @@ const LastMilePage = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, []);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [vehicleOptions]);
|
||||||
const tableStatus = "success" as const;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Card
|
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||||
radius="lg"
|
|
||||||
padding={0}
|
|
||||||
withBorder
|
|
||||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
|
||||||
>
|
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
@@ -739,18 +622,11 @@ const LastMilePage = () => {
|
|||||||
/>
|
/>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Button
|
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign}>
|
||||||
variant="light"
|
|
||||||
leftSection={<Truck size={16} />}
|
|
||||||
onClick={openBulkAssign}
|
|
||||||
>
|
|
||||||
Assign vehicle ({selectedIds.length})
|
Assign vehicle ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)}>
|
||||||
leftSection={<Truck size={16} />}
|
|
||||||
onClick={() => openAssign(null)}
|
|
||||||
>
|
|
||||||
Assign Mile
|
Assign Mile
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -768,7 +644,7 @@ const LastMilePage = () => {
|
|||||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.label} ({statusCounts[option.value]})
|
{option.label} ({statusCounts[option.value] ?? 0})
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -778,14 +654,14 @@ const LastMilePage = () => {
|
|||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={pagedJobs}
|
data={pagedRecords}
|
||||||
status={tableStatus}
|
status={isLoading ? "loading" : "success"}
|
||||||
emptyMessage="No last-mile deliveries found"
|
emptyMessage="No last-mile deliveries found"
|
||||||
pagination={{
|
pagination={{
|
||||||
pageIndex: pagination.pageIndex,
|
pageIndex: pagination.pageIndex,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
pageCount,
|
pageCount,
|
||||||
totalCount: filteredJobs.length,
|
totalCount: filteredRecords.length,
|
||||||
}}
|
}}
|
||||||
tableOptions={{
|
tableOptions={{
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
@@ -797,17 +673,14 @@ const LastMilePage = () => {
|
|||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
}}
|
}}
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
footer={({ table, pagination: footerPagination }) => (
|
footer={({ table, pagination: fp }) => (
|
||||||
<DataTableFooter
|
<DataTableFooter table={table} pagination={fp} options={{ labels: { items: "deliveries" } }} />
|
||||||
table={table}
|
|
||||||
pagination={footerPagination}
|
|
||||||
options={{ labels: { items: "deliveries" } }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Assign / Reassign modal */}
|
||||||
<Modal
|
<Modal
|
||||||
opened={assignOpen}
|
opened={assignOpen}
|
||||||
onClose={closeAssign}
|
onClose={closeAssign}
|
||||||
@@ -820,59 +693,54 @@ const LastMilePage = () => {
|
|||||||
{bulkMode ? (
|
{bulkMode ? (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Assigning a vehicle to{" "}
|
Assigning a vehicle to{" "}
|
||||||
<Text span fw={600}>
|
<Text span fw={600}>{selectedIds.length}</Text>{" "}
|
||||||
{selectedIds.length}
|
|
||||||
</Text>{" "}
|
|
||||||
selected {selectedIds.length === 1 ? "delivery" : "deliveries"}.
|
selected {selectedIds.length === 1 ? "delivery" : "deliveries"}.
|
||||||
</Text>
|
</Text>
|
||||||
) : activeJob ? (
|
) : activeRecord ? (
|
||||||
<BookingInfo job={activeJob} />
|
<BookingInfo record={activeRecord} />
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">No unassigned deliveries available.</Text>
|
||||||
No unassigned deliveries available.
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
<Select
|
<Select
|
||||||
label="Vehicle"
|
label="Vehicle"
|
||||||
placeholder="Select a vehicle"
|
placeholder="Select a vehicle"
|
||||||
data={VEHICLE_OPTIONS}
|
data={vehicleOptions}
|
||||||
value={vehicleValue}
|
value={vehicleValue}
|
||||||
onChange={setVehicleValue}
|
onChange={setVehicleValue}
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="default" onClick={closeAssign}>
|
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAssign}
|
onClick={handleAssign}
|
||||||
disabled={bulkMode ? selectedIds.length === 0 : !activeJob}
|
loading={updateMutation.isPending}
|
||||||
|
disabled={bulkMode ? selectedIds.length === 0 : !activeRecord}
|
||||||
>
|
>
|
||||||
{!bulkMode && activeJob?.status === "ASSIGNED" ? "Reassign" : "Assign"}
|
{!bulkMode && activeRecord && isAssigned(activeRecord) ? "Reassign" : "Assign"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* View detail modal */}
|
||||||
<Modal
|
<Modal
|
||||||
opened={detailOpen}
|
opened={detailOpen}
|
||||||
onClose={closeDetail}
|
onClose={() => { setDetailOpen(false); setActiveId(null); }}
|
||||||
title={<Text fw={600}>Delivery Detail</Text>}
|
title={<Text fw={600}>Delivery Detail</Text>}
|
||||||
size="lg"
|
size="lg"
|
||||||
radius="lg"
|
radius="lg"
|
||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{activeJob && <BookingInfo job={activeJob} />}
|
{activeRecord && <BookingInfo record={activeRecord} />}
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={closeDetail}>
|
<Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</Button>
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Trip slip modal */}
|
||||||
<Modal
|
<Modal
|
||||||
opened={tripSlipOpen}
|
opened={tripSlipOpen}
|
||||||
onClose={() => setTripSlipOpen(false)}
|
onClose={() => setTripSlipOpen(false)}
|
||||||
@@ -882,15 +750,11 @@ const LastMilePage = () => {
|
|||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{tripSlipJob && <TripSlipDocument job={tripSlipJob} />}
|
{tripSlipRecord && <TripSlipDocument record={tripSlipRecord} />}
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="default" onClick={() => setTripSlipOpen(false)}>
|
<Button variant="default" onClick={() => setTripSlipOpen(false)}>Close</Button>
|
||||||
Close
|
<Button leftSection={<Printer size={16} />} onClick={printTripSlip}>Print</Button>
|
||||||
</Button>
|
|
||||||
<Button leftSection={<Printer size={16} />} onClick={printTripSlip}>
|
|
||||||
Print
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { api } from '../auth/http';
|
||||||
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
|
|
||||||
|
export const FIRST_MILE_STATUSES = [
|
||||||
|
'PAYMENT_PENDING',
|
||||||
|
'READY_TO_TRANSIT',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'RECEIVED_TO_PORT',
|
||||||
|
] as const;
|
||||||
|
export type FirstMileApiStatus = (typeof FIRST_MILE_STATUSES)[number];
|
||||||
|
|
||||||
|
export interface FirstMileBooking {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
firstMilePickupAddress?: string | null;
|
||||||
|
cargoFreeText?: string | null;
|
||||||
|
cargoTotalWeightVgm: number;
|
||||||
|
totalAmount: number;
|
||||||
|
scheduledDate?: string | null;
|
||||||
|
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
|
||||||
|
serviceType?: { id: string; name?: string } | null;
|
||||||
|
originYard?: { id: string; name?: string } | null;
|
||||||
|
destinationYard?: { id: string; name?: string } | null;
|
||||||
|
cargoType?: { id: string; name?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstMileVehicle {
|
||||||
|
id: string;
|
||||||
|
plateNumber: string;
|
||||||
|
manufacturer: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstMileRecord {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
status: FirstMileApiStatus;
|
||||||
|
advancedPayment: number;
|
||||||
|
remainingPayment: number;
|
||||||
|
estimatedKm?: number | null;
|
||||||
|
exactKm?: number | null;
|
||||||
|
vehicleId?: string | null;
|
||||||
|
booking?: FirstMileBooking | null;
|
||||||
|
vehicle?: FirstMileVehicle | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstMileListResponse {
|
||||||
|
data: FirstMileRecord[];
|
||||||
|
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const FM = URL_CONSTANTS.FIRST_MILE;
|
||||||
|
|
||||||
|
export const firstMileService = {
|
||||||
|
list: (pageSize = 1000) =>
|
||||||
|
api.get<FirstMileListResponse>(`${FM.BASE}?pageSize=${pageSize}`),
|
||||||
|
getById: (id: string) => api.get<FirstMileRecord>(FM.BY_ID(id)),
|
||||||
|
update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null }) =>
|
||||||
|
api.patch<FirstMileRecord>(FM.BY_ID(id), data),
|
||||||
|
accept: (bookingReference: string) =>
|
||||||
|
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { api } from '../auth/http';
|
||||||
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
|
|
||||||
|
export const LAST_MILE_STATUSES = [
|
||||||
|
'PAYMENT_PENDING',
|
||||||
|
'READY_TO_TRANSIT',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'DELIVERED',
|
||||||
|
] as const;
|
||||||
|
export type LastMileApiStatus = (typeof LAST_MILE_STATUSES)[number];
|
||||||
|
|
||||||
|
export interface LastMileBooking {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
lastMileDeliveryAddress?: string | null;
|
||||||
|
cargoFreeText?: string | null;
|
||||||
|
cargoTotalWeightVgm: number;
|
||||||
|
totalAmount: number;
|
||||||
|
scheduledDate?: string | null;
|
||||||
|
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
|
||||||
|
serviceType?: { id: string; name?: string } | null;
|
||||||
|
originYard?: { id: string; name?: string } | null;
|
||||||
|
destinationYard?: { id: string; name?: string } | null;
|
||||||
|
cargoType?: { id: string; name?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastMileVehicle {
|
||||||
|
id: string;
|
||||||
|
plateNumber: string;
|
||||||
|
manufacturer: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastMileRecord {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
status: LastMileApiStatus;
|
||||||
|
advancedPayment: number;
|
||||||
|
remainingPayment: number;
|
||||||
|
estimatedKm?: number | null;
|
||||||
|
exactKm?: number | null;
|
||||||
|
vehicleId?: string | null;
|
||||||
|
booking?: LastMileBooking | null;
|
||||||
|
vehicle?: LastMileVehicle | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastMileListResponse {
|
||||||
|
data: LastMileRecord[];
|
||||||
|
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const LM = URL_CONSTANTS.LAST_MILE;
|
||||||
|
|
||||||
|
export const lastMileService = {
|
||||||
|
list: (pageSize = 1000) =>
|
||||||
|
api.get<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
|
||||||
|
getById: (id: string) => api.get<LastMileRecord>(LM.BY_ID(id)),
|
||||||
|
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null }) =>
|
||||||
|
api.patch<LastMileRecord>(LM.BY_ID(id), data),
|
||||||
|
accept: (bookingReference: string) =>
|
||||||
|
api.post<LastMileRecord>(LM.ACCEPT(bookingReference)),
|
||||||
|
};
|
||||||
@@ -297,7 +297,10 @@ export const api = {
|
|||||||
getAvailableDays: endpoint<
|
getAvailableDays: endpoint<
|
||||||
{ originYardId?: string; destinationYardId?: string },
|
{ originYardId?: string; destinationYardId?: string },
|
||||||
string[]
|
string[]
|
||||||
>("train-scheduling", "availableDays", ({ originYardId, destinationYardId }) =>
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"availableDays",
|
||||||
|
({ originYardId, destinationYardId }) =>
|
||||||
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -412,6 +412,17 @@ export class BookingsController {
|
|||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/usage')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Check if booking is in use',
|
||||||
|
description: 'Returns list of modules/data that reference this booking'
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||||
|
checkUsage(@Param('id') id: string) {
|
||||||
|
return this.service.checkBookingUsage(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':bookingRef')
|
@Get(':bookingRef')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Get booking details by reference (no auth required)',
|
summary: 'Get booking details by reference (no auth required)',
|
||||||
@@ -423,17 +434,6 @@ export class BookingsController {
|
|||||||
return this.service.getByRef(ref);
|
return this.service.getByRef(ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Update booking details',
|
|
||||||
description: 'Updates booking information for admin/agent operations'
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'Booking updated successfully' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
||||||
update(@Param('id') id: string, @Body() dto: any) {
|
|
||||||
return this.service.update(id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':bookingRef/modify')
|
@Patch(':bookingRef/modify')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@@ -458,15 +458,15 @@ export class BookingsController {
|
|||||||
return this.service.delete(id);
|
return this.service.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/usage')
|
@Patch(':id')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Check if booking is in use',
|
summary: 'Update booking details',
|
||||||
description: 'Returns list of modules/data that reference this booking'
|
description: 'Updates booking information for admin/agent operations'
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
|
@ApiResponse({ status: 200, description: 'Booking updated successfully' })
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||||
checkUsage(@Param('id') id: string) {
|
update(@Param('id') id: string, @Body() dto: any) {
|
||||||
return this.service.checkBookingUsage(id);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':bookingRef')
|
@Delete(':bookingRef')
|
||||||
|
|||||||
@@ -956,31 +956,47 @@ export class BookingsService {
|
|||||||
where: { bookingRef },
|
where: { bookingRef },
|
||||||
include: {
|
include: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
seats: { include: { seat: { include: { coach: true } } } },
|
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||||
paymentIntent: true, ticket: true,
|
paymentIntent: true, ticket: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!booking) throw new NotFoundException('Booking not found');
|
if (!booking) throw new NotFoundException('Booking not found');
|
||||||
return {
|
return {
|
||||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||||
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
|
totalMinor: booking.totalMinor, currency: 'ETB',
|
||||||
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
|
adultCount: booking.adultCount, childCount: booking.childCount,
|
||||||
|
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
|
||||||
bookingType: booking.bookingType,
|
bookingType: booking.bookingType,
|
||||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||||
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
|
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
|
||||||
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
|
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
|
||||||
|
contactEmail: booking.contactEmail,
|
||||||
|
contactPhone: booking.contactPhone,
|
||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
schedule: {
|
schedule: {
|
||||||
number: booking.schedule.train.number,
|
id: booking.schedule.id,
|
||||||
|
trainNumber: booking.schedule.train.number,
|
||||||
|
trainName: booking.schedule.train.name,
|
||||||
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
|
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
|
||||||
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
|
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
|
||||||
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
|
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
|
||||||
},
|
},
|
||||||
passengers: booking.seats?.map((bs: any) => ({
|
passengers: booking.seats?.map((bs: any) => ({
|
||||||
fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified,
|
fullName: bs.passengerName,
|
||||||
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name },
|
category: bs.passengerCategory,
|
||||||
|
leg: bs.leg ?? 1,
|
||||||
|
fareMinor: bs.fareMinor,
|
||||||
|
verifaydaVerified: bs.verifaydaVerified,
|
||||||
|
seat: {
|
||||||
|
id: bs.seat.id,
|
||||||
|
number: bs.seat.seatNumber,
|
||||||
|
coach: bs.seat.coach.number,
|
||||||
|
coachId: bs.seat.coach.id,
|
||||||
|
seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
|
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
|
||||||
|
ticket: booking.ticket ? { id: booking.ticket.id, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload, status: booking.ticket.status } : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,8 +191,8 @@ export class GuestBookingService {
|
|||||||
displayTotalMinor,
|
displayTotalMinor,
|
||||||
bookingType: 'ONE_WAY',
|
bookingType: 'ONE_WAY',
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
// contactEmail: firstPassenger.email, // Temporarily disabled until migration
|
contactEmail: firstPassenger.email || null,
|
||||||
// contactPhone: firstPassenger.phone, // Temporarily disabled until migration
|
contactPhone: firstPassenger.phone || null,
|
||||||
seats: {
|
seats: {
|
||||||
create: passengersData.map((p) => ({
|
create: passengersData.map((p) => ({
|
||||||
seat: { connect: { id: p.seatId } },
|
seat: { connect: { id: p.seatId } },
|
||||||
@@ -384,6 +384,8 @@ export class GuestBookingService {
|
|||||||
returnSeatClassId,
|
returnSeatClassId,
|
||||||
returnLegStatus: 'NEITHER_USED',
|
returnLegStatus: 'NEITHER_USED',
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
|
contactEmail: passengersData[0]?.email || null,
|
||||||
|
contactPhone: passengersData[0]?.phone || null,
|
||||||
seats: {
|
seats: {
|
||||||
create: [
|
create: [
|
||||||
...passengersData.map((p) => ({
|
...passengersData.map((p) => ({
|
||||||
@@ -574,6 +576,8 @@ export class GuestBookingService {
|
|||||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||||
leg2SeatClassId: leg2SeatClassId,
|
leg2SeatClassId: leg2SeatClassId,
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
|
contactEmail: passengersData[0]?.email || null,
|
||||||
|
contactPhone: passengersData[0]?.phone || null,
|
||||||
seats: {
|
seats: {
|
||||||
create: [
|
create: [
|
||||||
...passengersData.map(p => ({
|
...passengersData.map(p => ({
|
||||||
@@ -785,6 +789,8 @@ export class GuestBookingService {
|
|||||||
returnLeg2SeatClassId: retL2ClassId,
|
returnLeg2SeatClassId: retL2ClassId,
|
||||||
returnLegStatus: 'NEITHER_USED',
|
returnLegStatus: 'NEITHER_USED',
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
|
contactEmail: passengersData[0]?.email || null,
|
||||||
|
contactPhone: passengersData[0]?.phone || null,
|
||||||
seats: {
|
seats: {
|
||||||
create: [
|
create: [
|
||||||
...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
|
...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
|
||||||
@@ -860,12 +866,15 @@ export class GuestBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
|
|
||||||
if (firstPassenger.email) {
|
let guestEmail = firstPassenger.email;
|
||||||
const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
if (guestEmail) {
|
||||||
if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`;
|
const existing = await this.prisma.user.findUnique({ where: { email: guestEmail } });
|
||||||
|
if (existing) guestEmail = null;
|
||||||
}
|
}
|
||||||
let guestPhone = firstPassenger.phone || null;
|
if (!guestEmail) guestEmail = `guest-${uniqueId}@edr-platform.com`;
|
||||||
|
|
||||||
|
let guestPhone = firstPassenger.phone;
|
||||||
if (guestPhone) {
|
if (guestPhone) {
|
||||||
const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
|
const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
|
||||||
if (existing) guestPhone = null;
|
if (existing) guestPhone = null;
|
||||||
|
|||||||
@@ -93,10 +93,20 @@ export class TicketsService {
|
|||||||
include: {
|
include: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
seats: { include: { seat: { include: { coach: true } } } },
|
seats: { include: { seat: { include: { coach: true } } } },
|
||||||
|
paymentIntent: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
|
||||||
|
if (booking.status !== 'CONFIRMED') {
|
||||||
|
const paymentStatus = booking.paymentIntent?.status ?? null;
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Payment not completed. Please complete your payment before accessing the ticket. ` +
|
||||||
|
`Booking status: ${booking.status}` +
|
||||||
|
(paymentStatus ? `. Payment status: ${paymentStatus}` : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Build a compact multi-leg payload for the QR so gate scanners see all legs
|
// Build a compact multi-leg payload for the QR so gate scanners see all legs
|
||||||
const legSummary = this.buildLegSummary(booking);
|
const legSummary = this.buildLegSummary(booking);
|
||||||
const qrData = JSON.stringify({
|
const qrData = JSON.stringify({
|
||||||
|
|||||||
@@ -13,13 +13,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
"@edr/ui-common": "workspace:*",
|
"@edr/ui-common": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
|
"@tanstack/react-query": "^5.59.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.0.0",
|
"date-fns": "^3.0.0",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
|
"jspdf-autotable": "^5.0.8",
|
||||||
"lucide-react": "^0.446.0",
|
"lucide-react": "^0.446.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"qrcode.react": "^3.1.0",
|
"qrcode.react": "^3.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useBookingStore } from '@/lib/booking-store';
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train } from 'lucide-react';
|
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ export default function ConfirmationPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore();
|
const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||||
const confirmAttempted = useRef(false);
|
const confirmAttempted = useRef(false);
|
||||||
|
|
||||||
const confirmMutation = useMutation({
|
const confirmMutation = useMutation({
|
||||||
@@ -69,8 +70,61 @@ export default function ConfirmationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadTickets = () => {
|
const handleDownloadVoucher = async () => {
|
||||||
alert('Ticket download will be available soon. Your tickets are displayed below.');
|
if (!_booking || !pnr) {
|
||||||
|
alert('Booking data not available. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingVoucher(true);
|
||||||
|
try {
|
||||||
|
console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers });
|
||||||
|
|
||||||
|
const { generateVoucherPDF } = await import('@/lib/generate-voucher');
|
||||||
|
|
||||||
|
const voucherData = {
|
||||||
|
bookingRef: pnr,
|
||||||
|
status: _booking.status || 'CONFIRMED',
|
||||||
|
passengers: passengers.map(p => ({
|
||||||
|
fullName: p.name,
|
||||||
|
category: 'ADULT',
|
||||||
|
seat: p.seatNumber ? {
|
||||||
|
number: p.seatNumber,
|
||||||
|
coach: 'N/A',
|
||||||
|
seatClass: selectedSchedule?.selectedSeatClassName || 'Standard',
|
||||||
|
} : undefined,
|
||||||
|
})),
|
||||||
|
schedule: {
|
||||||
|
trainNumber: selectedSchedule?.trainNumber || 'N/A',
|
||||||
|
trainName: 'EDR Express',
|
||||||
|
origin: {
|
||||||
|
name: selectedSchedule?.origin || 'Origin',
|
||||||
|
code: 'ORG',
|
||||||
|
city: selectedSchedule?.origin || 'Origin',
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
name: selectedSchedule?.destination || 'Destination',
|
||||||
|
code: 'DST',
|
||||||
|
city: selectedSchedule?.destination || 'Destination',
|
||||||
|
},
|
||||||
|
departureAt: selectedSchedule?.departureTime || new Date().toISOString(),
|
||||||
|
arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(),
|
||||||
|
},
|
||||||
|
totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
|
||||||
|
currency: 'ETB',
|
||||||
|
bookingType: 'ONE_WAY',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📄 Voucher data prepared:', voucherData);
|
||||||
|
await generateVoucherPDF(voucherData);
|
||||||
|
console.log('✅ Voucher generated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to generate voucher:', error);
|
||||||
|
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingVoucher(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrintTickets = () => {
|
const handlePrintTickets = () => {
|
||||||
@@ -131,8 +185,22 @@ export default function ConfirmationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trip Summary */}
|
{/* Trip Summary with QR Code */}
|
||||||
<div className="card mb-6">
|
<div className="card mb-6">
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
{/* QR Code Section */}
|
||||||
|
<div className="flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg p-6 md:w-48 flex-shrink-0">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={pnr}
|
||||||
|
size={160}
|
||||||
|
level="H"
|
||||||
|
includeMargin={true}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center font-semibold">Scan at gate</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trip Details */}
|
||||||
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
|
||||||
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
|
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
|
||||||
@@ -176,6 +244,8 @@ export default function ConfirmationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tickets */}
|
{/* Tickets */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -184,17 +254,9 @@ export default function ConfirmationPage() {
|
|||||||
{passengers.map((passenger, index) => {
|
{passengers.map((passenger, index) => {
|
||||||
const backendTicket = _booking?.ticket || null;
|
const backendTicket = _booking?.ticket || null;
|
||||||
const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`;
|
const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`;
|
||||||
const qrData = backendTicket?.qrPayload || JSON.stringify({
|
|
||||||
pnr,
|
|
||||||
ticketNumber,
|
|
||||||
passengerName: passenger.name,
|
|
||||||
trainNumber: selectedSchedule?.trainNumber,
|
|
||||||
date: selectedSchedule?.departureTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="card hover:shadow-lg transition-shadow">
|
<div key={index} className="card hover:shadow-lg transition-shadow">
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
|
||||||
{/* Ticket Info */}
|
{/* Ticket Info */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
@@ -223,24 +285,6 @@ export default function ConfirmationPage() {
|
|||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.seatNumber || 'Will be assigned'}</p>
|
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.seatNumber || 'Will be assigned'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
||||||
<p className="text-xs text-yellow-800 dark:text-yellow-300">
|
|
||||||
📱 Show this QR code at the gate for boarding
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* QR Code */}
|
|
||||||
<div className="flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg p-6">
|
|
||||||
<QRCodeSVG
|
|
||||||
value={qrData}
|
|
||||||
size={160}
|
|
||||||
level="H"
|
|
||||||
includeMargin={true}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center">Scan at gate</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -249,13 +293,24 @@ export default function ConfirmationPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
||||||
<button
|
<button
|
||||||
onClick={handleDownloadTickets}
|
onClick={handleDownloadVoucher}
|
||||||
className="btn-secondary flex items-center justify-center gap-2"
|
disabled={isGeneratingVoucher}
|
||||||
|
className="btn-primary flex items-center justify-center gap-2 relative"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
{isGeneratingVoucher ? (
|
||||||
<span className="hidden sm:inline">Download</span>
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="hidden sm:inline">Generating...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Download Voucher</span>
|
||||||
|
<span className="sm:hidden">Voucher</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePrintTickets}
|
onClick={handlePrintTickets}
|
||||||
@@ -271,6 +326,13 @@ export default function ConfirmationPage() {
|
|||||||
<Mail className="w-4 h-4" />
|
<Mail className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Email</span>
|
<span className="hidden sm:inline">Email</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => alert('Tickets download will be available soon.')}
|
||||||
|
className="btn-secondary flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Download</span>
|
||||||
|
</button>
|
||||||
<button className="btn-secondary flex items-center justify-center gap-2">
|
<button className="btn-secondary flex items-center justify-center gap-2">
|
||||||
<Share2 className="w-4 h-4" />
|
<Share2 className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Share</span>
|
<span className="hidden sm:inline">Share</span>
|
||||||
|
|||||||
645
apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
Normal file
645
apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Download,
|
||||||
|
Share2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
CreditCard,
|
||||||
|
Wallet
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import QRCode from 'qrcode.react';
|
||||||
|
|
||||||
|
function BookingDetailContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const bookingRef = searchParams.get('ref') || searchParams.get('bookingRef') || searchParams.get('pnr');
|
||||||
|
|
||||||
|
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('');
|
||||||
|
const [copiedPNR, setCopiedPNR] = useState(false);
|
||||||
|
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||||
|
|
||||||
|
const { data: booking, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['booking-detail', bookingRef],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!bookingRef) throw new Error('No booking reference provided');
|
||||||
|
console.log('🔍 Fetching Booking:', bookingRef);
|
||||||
|
const response = await apiClient.get(`/bookings/${bookingRef}`);
|
||||||
|
console.log('✅ Booking Response:', response);
|
||||||
|
// Handle wrapped response
|
||||||
|
return (response as any)?.data || response;
|
||||||
|
},
|
||||||
|
enabled: !!bookingRef,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: paymentMethods } = useQuery({
|
||||||
|
queryKey: ['payment-methods'],
|
||||||
|
queryFn: () => apiClient.get('/payments/methods'),
|
||||||
|
enabled: booking?.status === 'PENDING_PAYMENT' || booking?.status === 'DRAFT',
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentMutation = useMutation({
|
||||||
|
mutationFn: async (paymentData: any) => {
|
||||||
|
const response = await apiClient.post('/payments/intent', paymentData);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
onSuccess: async (data: any) => {
|
||||||
|
console.log('Payment intent created:', data);
|
||||||
|
await apiClient.patch(`/bookings/${booking?.id}/confirm`, {
|
||||||
|
paymentIntentId: data.id,
|
||||||
|
paymentMethod: selectedPaymentMethod,
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('Payment failed:', error);
|
||||||
|
alert(error?.response?.data?.message || 'Payment failed. Please try again.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePayment = () => {
|
||||||
|
if (!selectedPaymentMethod) {
|
||||||
|
alert('Please select a payment method');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentMutation.mutate({
|
||||||
|
bookingId: booking?.id,
|
||||||
|
amount: booking?.totalMinor || 0,
|
||||||
|
currency: booking?.currency || 'ETB',
|
||||||
|
paymentMethodId: selectedPaymentMethod,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyPNR = () => {
|
||||||
|
if (booking?.bookingRef) {
|
||||||
|
navigator.clipboard.writeText(booking.bookingRef);
|
||||||
|
setCopiedPNR(true);
|
||||||
|
setTimeout(() => setCopiedPNR(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadVoucher = async () => {
|
||||||
|
if (!booking || !booking.bookingRef) {
|
||||||
|
alert('Booking data not available. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingVoucher(true);
|
||||||
|
try {
|
||||||
|
console.log('📄 Generating voucher for booking:', booking);
|
||||||
|
const { generateVoucherPDF } = await import('@/lib/generate-voucher');
|
||||||
|
await generateVoucherPDF(booking as any);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate voucher:', error);
|
||||||
|
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingVoucher(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
|
||||||
|
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Loading Booking Details</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Fetching your booking information...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !booking) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
|
||||||
|
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Booking Not Found</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
{!bookingRef
|
||||||
|
? 'No booking reference provided in the URL.'
|
||||||
|
: `Unable to find booking with reference: ${bookingRef}`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button onClick={() => refetch()} className="btn-secondary mb-2">Try Again</button>
|
||||||
|
<button onClick={() => router.push('/booking/search')} className="btn-primary">New Booking</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPendingPayment = booking.status === 'PENDING_PAYMENT' || booking.status === 'DRAFT';
|
||||||
|
const isConfirmed = booking.status === 'TICKETED' || booking.status === 'CONFIRMED';
|
||||||
|
const isExpired = booking.status === 'EXPIRED';
|
||||||
|
const isCancelled = booking.status === 'CANCELLED';
|
||||||
|
|
||||||
|
console.log('📊 Booking Status:', booking.status);
|
||||||
|
console.log('📊 isPendingPayment:', isPendingPayment);
|
||||||
|
console.log('📊 isConfirmed:', isConfirmed);
|
||||||
|
console.log('📊 isExpired:', isExpired);
|
||||||
|
console.log('📊 isCancelled:', isCancelled);
|
||||||
|
|
||||||
|
const StatusBadge = () => {
|
||||||
|
const statusConfig = {
|
||||||
|
PENDING_PAYMENT: { color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', label: 'Pending Payment' },
|
||||||
|
DRAFT: { color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', label: 'Pending Payment' },
|
||||||
|
CONFIRMED: { color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', label: 'Confirmed' },
|
||||||
|
TICKETED: { color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', label: 'Ticketed' },
|
||||||
|
EXPIRED: { color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', label: 'Expired' },
|
||||||
|
CANCELLED: { color: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', label: 'Cancelled' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[booking.status as keyof typeof statusConfig] || statusConfig.DRAFT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-semibold ${config.color}`}>
|
||||||
|
{isConfirmed && <CheckCircle2 className="w-4 h-4" />}
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isPendingPayment && !isExpired) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Complete Payment</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Booking Reference: <span className="font-mono font-semibold">{booking.bookingRef}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{booking.createdAt && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 flex items-center gap-2">
|
||||||
|
<Clock className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
Booking created on {format(new Date(booking.createdAt), 'PPpp')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Trip Summary</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Your Journey</span>
|
||||||
|
{booking.passengers?.[0]?.seat?.seatClass && (
|
||||||
|
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
||||||
|
{booking.passengers[0].seat.seatClass}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flight-style timeline */}
|
||||||
|
<div className="flex">
|
||||||
|
{/* Left column: Timeline with dots and line */}
|
||||||
|
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||||
|
{/* Origin dot */}
|
||||||
|
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
||||||
|
{/* Vertical line */}
|
||||||
|
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||||
|
{/* Destination dot */}
|
||||||
|
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column: Content */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{/* Origin */}
|
||||||
|
<div className="pb-8">
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
|
{booking.schedule?.origin?.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{booking.schedule?.origin?.city}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Journey Info */}
|
||||||
|
<div className="pb-8">
|
||||||
|
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">Train {booking.schedule?.trainNumber}</span>
|
||||||
|
</div>
|
||||||
|
{booking.schedule?.trainName && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{booking.schedule.trainName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destination */}
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
|
{booking.schedule?.destination?.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{booking.schedule?.destination?.city}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
{booking.passengers?.length || 0} Passenger(s)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{booking.passengers?.map((passenger: any, idx: number) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between text-sm py-2 px-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-900 dark:text-white font-medium">{passenger.fullName}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{passenger.category} • Coach {passenger.seat?.coach}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
Seat {passenger.seat?.number}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{passenger.seat?.seatClass}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Select Payment Method</h2>
|
||||||
|
|
||||||
|
{paymentMethods && Array.isArray(paymentMethods) && paymentMethods.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{paymentMethods.map((method: any) => (
|
||||||
|
<button
|
||||||
|
key={method.id}
|
||||||
|
onClick={() => setSelectedPaymentMethod(method.id)}
|
||||||
|
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||||
|
selectedPaymentMethod === method.id
|
||||||
|
? 'border-primary bg-primary/5 dark:bg-primary/10'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||||
|
selectedPaymentMethod === method.id
|
||||||
|
? 'bg-primary/20 dark:bg-primary/30'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700'
|
||||||
|
}`}>
|
||||||
|
{method.type === 'WALLET' ? (
|
||||||
|
<Wallet className={`w-5 h-5 ${selectedPaymentMethod === method.id ? 'text-primary' : 'text-gray-600 dark:text-gray-400'}`} />
|
||||||
|
) : (
|
||||||
|
<CreditCard className={`w-5 h-5 ${selectedPaymentMethod === method.id ? 'text-primary' : 'text-gray-600 dark:text-gray-400'}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-white">{method.displayName}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{method.currency}</div>
|
||||||
|
</div>
|
||||||
|
{selectedPaymentMethod === method.id && (
|
||||||
|
<Check className="w-5 h-5 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
No payment methods available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePayment}
|
||||||
|
disabled={!selectedPaymentMethod || paymentMutation.isPending}
|
||||||
|
className="w-full py-4 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-lg rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
|
||||||
|
>
|
||||||
|
{paymentMutation.isPending ? 'Processing Payment...' : `Pay ${booking.displayCurrency} ${((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700 sticky top-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Order Summary</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Subtotal ({booking.adultCount} Adult{booking.adultCount > 1 ? 's' : ''}{booking.childCount > 0 ? `, ${booking.childCount} Child${booking.childCount > 1 ? 'ren' : ''}` : ''})</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{booking.currency} {((booking.totalMinor || 0) / 100).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-lg font-bold text-gray-900 dark:text-white">Total</span>
|
||||||
|
<span className="text-2xl font-bold text-primary">
|
||||||
|
{booking.displayCurrency} {((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConfirmed || isCancelled || isExpired) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 mb-6 text-center border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className={`w-20 h-20 ${isConfirmed ? 'bg-green-100 dark:bg-green-900/30' : 'bg-gray-100 dark:bg-gray-700'} rounded-full flex items-center justify-center mx-auto mb-4`}>
|
||||||
|
{isConfirmed ? (
|
||||||
|
<CheckCircle2 className="w-10 h-10 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-10 h-10 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
{isConfirmed ? 'Booking Confirmed!' : isCancelled ? 'Booking Cancelled' : 'Booking Expired'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{isConfirmed ? 'Your tickets have been generated successfully' : isCancelled ? 'This booking has been cancelled' : 'This booking has expired'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="inline-flex items-center gap-3 bg-gray-50 dark:bg-gray-900 rounded-xl px-6 py-4">
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Booking Reference</div>
|
||||||
|
<div className="text-2xl font-mono font-bold text-primary">{booking.bookingRef}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={copyPNR}
|
||||||
|
className="w-10 h-10 rounded-lg bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 flex items-center justify-center transition-all"
|
||||||
|
>
|
||||||
|
{copiedPNR ? <Check className="w-5 h-5 text-green-600" /> : <Copy className="w-5 h-5 text-gray-600 dark:text-gray-400" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 justify-center mt-6">
|
||||||
|
{isConfirmed && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadVoucher}
|
||||||
|
disabled={isGeneratingVoucher}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isGeneratingVoucher ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Download Voucher
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button className="btn-secondary flex items-center gap-2">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Download Tickets
|
||||||
|
</button>
|
||||||
|
<button className="btn-secondary flex items-center gap-2">
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 mb-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Journey Details</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Your Journey</span>
|
||||||
|
{booking.passengers?.[0]?.seat?.seatClass && (
|
||||||
|
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
||||||
|
{booking.passengers[0].seat.seatClass}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flight-style timeline */}
|
||||||
|
<div className="flex">
|
||||||
|
{/* Left column: Timeline with dots and line */}
|
||||||
|
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
||||||
|
{/* Origin dot */}
|
||||||
|
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
||||||
|
{/* Vertical line */}
|
||||||
|
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
||||||
|
{/* Destination dot */}
|
||||||
|
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column: Content */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{/* Origin */}
|
||||||
|
<div className="pb-8">
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
|
{booking.schedule?.origin?.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{booking.schedule?.origin?.city}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Journey Info */}
|
||||||
|
<div className="pb-8">
|
||||||
|
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">Train {booking.schedule?.trainNumber}</span>
|
||||||
|
</div>
|
||||||
|
{booking.schedule?.trainName && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{booking.schedule.trainName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destination */}
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
|
{booking.schedule?.destination?.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{booking.schedule?.destination?.city}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Passenger Details ({booking.passengers?.length || 0})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{booking.passengers?.map((passenger: any, idx: number) => (
|
||||||
|
<div key={idx} className="border border-gray-200 dark:border-gray-700 rounded-xl p-4">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="w-6 h-6 bg-primary text-white rounded-full flex items-center justify-center text-xs font-bold">
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">{passenger.fullName}</h3>
|
||||||
|
<span className="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-full">
|
||||||
|
{passenger.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Coach:</span>
|
||||||
|
<div className="font-mono font-semibold text-gray-900 dark:text-white">
|
||||||
|
{passenger.seat?.coach || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Seat Number:</span>
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{passenger.seat?.number || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Class:</span>
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{passenger.seat?.seatClass || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isConfirmed && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="bg-white p-3 rounded-lg border-2 border-gray-200">
|
||||||
|
<QRCode
|
||||||
|
value={`TICKET:${booking.bookingRef}-${passenger.seat?.id || idx}`}
|
||||||
|
size={80}
|
||||||
|
level="M"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button onClick={() => router.push('/booking/search')} className="btn-primary">
|
||||||
|
Book Another Trip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for any other status
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
|
||||||
|
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<AlertCircle className="w-8 h-8 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Unknown Booking Status</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
Booking status: {booking.status}
|
||||||
|
</p>
|
||||||
|
<button onClick={() => router.push('/booking/search')} className="btn-primary">New Booking</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookingDetailPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center max-w-md w-full">
|
||||||
|
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Loading...</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<BookingDetailContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export default function BookingLayout({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentStep = stepMap[pathname] || 'search';
|
const currentStep = stepMap[pathname] || 'search';
|
||||||
const showProgress = pathname !== '/booking/search' && pathname !== '/booking/confirmation';
|
const showProgress = pathname !== '/booking/search' && pathname !== '/booking/confirmation' && pathname !== '/booking/detail' && pathname !== '/booking/lookup';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function BookingLookupPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [bookingRef, setBookingRef] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = bookingRef.trim().toUpperCase();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError("Please enter a booking reference");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/booking/detail?ref=${trimmed}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center p-4 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-[rgb(20,113,76)] bg-opacity-10 rounded-full mb-4">
|
||||||
|
<Search className="w-8 h-8 text-[rgb(20,113,76)]" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Find Your Booking
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Enter your booking reference (PNR) to view details
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Booking Reference (PNR)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bookingRef}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBookingRef(e.target.value.toUpperCase());
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
placeholder="Enter your PNR"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg font-mono"
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Search className="w-5 h-5" />
|
||||||
|
Search Booking
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -557,11 +557,14 @@ export default function PassengersPage() {
|
|||||||
nationalId: p.nationalId,
|
nationalId: p.nationalId,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
phone: p.phone,
|
passportIssueDate: p.passportIssueDate,
|
||||||
email: p.email,
|
passportExpiryDate: p.passportExpiryDate,
|
||||||
|
passportIssuingAuthority: p.passportIssuingAuthority,
|
||||||
|
phone: p.phone || '',
|
||||||
|
email: p.email || '',
|
||||||
isPrimaryPassenger: i === 0,
|
isPrimaryPassenger: i === 0,
|
||||||
passengerId: i === 0 && passengerId ? passengerId : undefined,
|
passengerId: i === 0 && passengerId ? passengerId : undefined,
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const deviceId = typeof window !== 'undefined'
|
const deviceId = typeof window !== 'undefined'
|
||||||
? (localStorage.getItem('deviceId') || crypto.randomUUID())
|
? (localStorage.getItem('deviceId') || crypto.randomUUID())
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||||
|
|
||||||
export default function AppHeader() {
|
export default function AppHeader() {
|
||||||
const pathname = usePathname();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isDark, setIsDark] = useState(false);
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
|
||||||
@@ -31,13 +29,7 @@ export default function AppHeader() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLandingPage = [
|
|
||||||
"/",
|
|
||||||
"/services",
|
|
||||||
"/about",
|
|
||||||
"/contact",
|
|
||||||
"/help",
|
|
||||||
].includes(pathname);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm">
|
<header className="sticky top-0 z-50 bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm">
|
||||||
@@ -59,35 +51,15 @@ export default function AppHeader() {
|
|||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Menu - only show for landing pages */}
|
{/* Desktop Menu */}
|
||||||
{isLandingPage && (
|
|
||||||
<div className="hidden md:flex items-center gap-8">
|
<div className="hidden md:flex items-center gap-8">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/booking/lookup"
|
||||||
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
|
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Home
|
My Booking
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/services"
|
|
||||||
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Services
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/about"
|
|
||||||
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
About
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/contact"
|
|
||||||
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Right Actions */}
|
{/* Right Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -133,39 +105,13 @@ export default function AppHeader() {
|
|||||||
{/* Mobile Menu */}
|
{/* Mobile Menu */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="md:hidden border-t border-white border-opacity-20 dark:border-gray-700 py-4 space-y-2 animate-in slide-in-from-top-2 duration-200">
|
<div className="md:hidden border-t border-white border-opacity-20 dark:border-gray-700 py-4 space-y-2 animate-in slide-in-from-top-2 duration-200">
|
||||||
{isLandingPage && (
|
|
||||||
<>
|
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/booking/lookup"
|
||||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
Home
|
My Booking
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/services"
|
|
||||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
>
|
|
||||||
Services
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/about"
|
|
||||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
>
|
|
||||||
About
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/contact"
|
|
||||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/help"
|
href="/help"
|
||||||
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
|||||||
392
apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
Normal file
392
apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
|
||||||
|
interface VoucherData {
|
||||||
|
bookingRef: string;
|
||||||
|
status: string;
|
||||||
|
passengers: Array<{
|
||||||
|
fullName: string;
|
||||||
|
category: string;
|
||||||
|
seat?: {
|
||||||
|
number: string;
|
||||||
|
coach: string;
|
||||||
|
seatClass: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
schedule: {
|
||||||
|
trainNumber: string;
|
||||||
|
trainName?: string;
|
||||||
|
origin: {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
city: string;
|
||||||
|
};
|
||||||
|
destination: {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
city: string;
|
||||||
|
};
|
||||||
|
departureAt: string;
|
||||||
|
arrivalAt: string;
|
||||||
|
};
|
||||||
|
totalMinor: number;
|
||||||
|
currency: string;
|
||||||
|
bookingType: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateVoucherPDF = async (booking: VoucherData) => {
|
||||||
|
const doc = new jsPDF({
|
||||||
|
orientation: 'portrait',
|
||||||
|
unit: 'mm',
|
||||||
|
format: 'a4',
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight();
|
||||||
|
const margin = 15;
|
||||||
|
let yPos = margin;
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
const primaryColor = [20, 113, 76]; // EDR Green
|
||||||
|
const darkGray = [51, 51, 51];
|
||||||
|
const mediumGray = [102, 102, 102];
|
||||||
|
const lightGray = [200, 200, 200];
|
||||||
|
|
||||||
|
// ============ HEADER ============
|
||||||
|
// Company branding strip
|
||||||
|
doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||||
|
doc.rect(0, 0, pageWidth, 30, 'F');
|
||||||
|
|
||||||
|
// Load and add logo
|
||||||
|
try {
|
||||||
|
const logoImg = await fetch('/edr-logo.png');
|
||||||
|
const logoBlob = await logoImg.blob();
|
||||||
|
const logoDataUrl = await new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.readAsDataURL(logoBlob);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create image to get dimensions
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
img.onload = resolve;
|
||||||
|
img.src = logoDataUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate aspect ratio and dimensions
|
||||||
|
const logoHeight = 18;
|
||||||
|
const logoWidth = (img.width / img.height) * logoHeight;
|
||||||
|
|
||||||
|
// Add logo on left side with proper aspect ratio
|
||||||
|
doc.addImage(logoDataUrl, 'PNG', margin, 6, logoWidth, logoHeight);
|
||||||
|
|
||||||
|
// Company name next to logo
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoWidth + 5, 14);
|
||||||
|
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('Premium Travel Experience', margin + logoWidth + 5, 20);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load logo:', error);
|
||||||
|
// Fallback: just show text centered
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.setFontSize(24);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 12, { align: 'center' });
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('Premium Travel Experience', pageWidth / 2, 18, { align: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
yPos = 40;
|
||||||
|
|
||||||
|
// ============ TITLE & STATUS ============
|
||||||
|
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('BOOKING VOUCHER', pageWidth / 2, yPos, { align: 'center' });
|
||||||
|
|
||||||
|
yPos += 10;
|
||||||
|
|
||||||
|
// Status badge (simplified)
|
||||||
|
const statusText = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? 'CONFIRMED' : booking.status;
|
||||||
|
const statusColor = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? [34, 197, 94] : [234, 179, 8];
|
||||||
|
|
||||||
|
doc.setFillColor(statusColor[0], statusColor[1], statusColor[2]);
|
||||||
|
doc.rect(pageWidth / 2 - 20, yPos - 4, 40, 8, 'F');
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(statusText, pageWidth / 2, yPos + 1, { align: 'center' });
|
||||||
|
|
||||||
|
yPos += 12;
|
||||||
|
|
||||||
|
// ============ QR CODE ============
|
||||||
|
// Generate QR code data URL
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const QRCode = (await import('qrcode')).default;
|
||||||
|
|
||||||
|
const qrSize = 35; // 35mm = 3.5cm
|
||||||
|
await QRCode.toCanvas(canvas, booking.bookingRef, {
|
||||||
|
width: 300,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const qrDataUrl = canvas.toDataURL('image/png');
|
||||||
|
|
||||||
|
// Place QR code at top-right
|
||||||
|
const qrX = pageWidth - margin - qrSize;
|
||||||
|
const qrY = yPos;
|
||||||
|
|
||||||
|
doc.addImage(qrDataUrl, 'PNG', qrX, qrY, qrSize, qrSize);
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('SCAN AT TERMINAL', qrX + qrSize / 2, qrY + qrSize + 4, { align: 'center' });
|
||||||
|
|
||||||
|
// ============ BOOKING REFERENCE ============
|
||||||
|
doc.setFillColor(245, 245, 245);
|
||||||
|
doc.rect(margin, yPos, pageWidth - margin * 2 - qrSize - 5, 18, 'F');
|
||||||
|
|
||||||
|
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('BOOKING REFERENCE', margin + 5, yPos + 6);
|
||||||
|
|
||||||
|
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||||
|
doc.setFontSize(18);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(booking.bookingRef, margin + 5, yPos + 14);
|
||||||
|
|
||||||
|
yPos += 25;
|
||||||
|
|
||||||
|
// ============ JOURNEY DETAILS ============
|
||||||
|
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('JOURNEY DETAILS', margin, yPos);
|
||||||
|
|
||||||
|
yPos += 8;
|
||||||
|
|
||||||
|
// Route box
|
||||||
|
doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]);
|
||||||
|
doc.setLineWidth(0.5);
|
||||||
|
doc.rect(margin, yPos, pageWidth - margin * 2, 40);
|
||||||
|
|
||||||
|
// Origin
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('FROM', margin + 5, yPos + 6);
|
||||||
|
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(booking.schedule.origin.code, margin + 5, yPos + 14);
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(booking.schedule.origin.name, margin + 5, yPos + 20);
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||||
|
doc.text(booking.schedule.origin.city, margin + 5, yPos + 25);
|
||||||
|
|
||||||
|
// Departure time
|
||||||
|
const departureDate = new Date(booking.schedule.departureAt);
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(departureDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, yPos + 33);
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(departureDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, yPos + 38);
|
||||||
|
|
||||||
|
// Arrow
|
||||||
|
doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||||
|
doc.setLineWidth(1);
|
||||||
|
const arrowStartX = pageWidth / 2 - 10;
|
||||||
|
const arrowEndX = pageWidth / 2 + 10;
|
||||||
|
const arrowY = yPos + 20;
|
||||||
|
|
||||||
|
// Draw arrow line
|
||||||
|
doc.line(arrowStartX, arrowY, arrowEndX, arrowY);
|
||||||
|
|
||||||
|
// Draw arrow head manually with lines
|
||||||
|
doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY - 2);
|
||||||
|
doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY + 2);
|
||||||
|
|
||||||
|
// Destination
|
||||||
|
const destX = pageWidth - margin - 50;
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('TO', destX, yPos + 6);
|
||||||
|
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(booking.schedule.destination.code, destX, yPos + 14);
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(booking.schedule.destination.name, destX, yPos + 20);
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||||
|
doc.text(booking.schedule.destination.city, destX, yPos + 25);
|
||||||
|
|
||||||
|
// Arrival time
|
||||||
|
const arrivalDate = new Date(booking.schedule.arrivalAt);
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(arrivalDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), destX, yPos + 33);
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(arrivalDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), destX, yPos + 38);
|
||||||
|
|
||||||
|
yPos += 48;
|
||||||
|
|
||||||
|
// Train info
|
||||||
|
doc.setFillColor(250, 250, 250);
|
||||||
|
doc.rect(margin, yPos, pageWidth - margin * 2, 12, 'F');
|
||||||
|
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('TRAIN', margin + 5, yPos + 5);
|
||||||
|
|
||||||
|
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(booking.schedule.trainNumber, margin + 5, yPos + 9);
|
||||||
|
|
||||||
|
if (booking.schedule.trainName) {
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(` - ${booking.schedule.trainName}`, margin + 25, yPos + 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
yPos += 18;
|
||||||
|
|
||||||
|
// ============ PASSENGERS ============
|
||||||
|
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('PASSENGERS', margin, yPos);
|
||||||
|
|
||||||
|
yPos += 8;
|
||||||
|
|
||||||
|
// Passenger table
|
||||||
|
const passengerData = booking.passengers.map((p, idx) => [
|
||||||
|
(idx + 1).toString(),
|
||||||
|
p.fullName,
|
||||||
|
p.category,
|
||||||
|
p.seat?.number || '-',
|
||||||
|
p.seat?.coach || '-',
|
||||||
|
p.seat?.seatClass || '-',
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [['#', 'Passenger Name', 'Type', 'Seat', 'Coach', 'Class']],
|
||||||
|
body: passengerData,
|
||||||
|
theme: 'striped',
|
||||||
|
headStyles: {
|
||||||
|
fillColor: [primaryColor[0], primaryColor[1], primaryColor[2]],
|
||||||
|
textColor: [255, 255, 255],
|
||||||
|
fontSize: 9,
|
||||||
|
fontStyle: 'bold',
|
||||||
|
},
|
||||||
|
bodyStyles: {
|
||||||
|
fontSize: 9,
|
||||||
|
textColor: [darkGray[0], darkGray[1], darkGray[2]],
|
||||||
|
},
|
||||||
|
alternateRowStyles: {
|
||||||
|
fillColor: [250, 250, 250],
|
||||||
|
},
|
||||||
|
margin: { left: margin, right: margin },
|
||||||
|
});
|
||||||
|
|
||||||
|
yPos = (doc as any).lastAutoTable.finalY + 10;
|
||||||
|
|
||||||
|
// ============ PAYMENT SUMMARY ============
|
||||||
|
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('PAYMENT SUMMARY', margin, yPos);
|
||||||
|
|
||||||
|
yPos += 8;
|
||||||
|
|
||||||
|
doc.setFillColor(250, 250, 250);
|
||||||
|
doc.rect(margin, yPos, pageWidth - margin * 2, 20, 'F');
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('Total Amount', margin + 5, yPos + 7);
|
||||||
|
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`${booking.currency} ${(booking.totalMinor / 100).toFixed(2)}`, pageWidth - margin - 5, yPos + 7, { align: 'right' });
|
||||||
|
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(34, 197, 94);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('✓ PAID', margin + 5, yPos + 15);
|
||||||
|
|
||||||
|
yPos += 28;
|
||||||
|
|
||||||
|
// ============ INSTRUCTIONS ============
|
||||||
|
doc.setFillColor(252, 211, 77);
|
||||||
|
doc.rect(margin, yPos, pageWidth - margin * 2, 18, 'F');
|
||||||
|
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, yPos + 6);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.text('• Present this voucher at the terminal for boarding', margin + 5, yPos + 11);
|
||||||
|
doc.text('• Arrive at least 30 minutes before departure', margin + 5, yPos + 15);
|
||||||
|
|
||||||
|
// ============ FOOTER ============
|
||||||
|
const footerY = pageHeight - 25;
|
||||||
|
|
||||||
|
doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]);
|
||||||
|
doc.line(margin, footerY, pageWidth - margin, footerY);
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' });
|
||||||
|
doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' });
|
||||||
|
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.text(`Generated: ${new Date().toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' });
|
||||||
|
|
||||||
|
// Watermark (removed rotation as it may cause issues)
|
||||||
|
doc.setTextColor(240, 240, 240);
|
||||||
|
doc.setFontSize(50);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('EDR', pageWidth / 2, pageHeight / 2, { align: 'center' });
|
||||||
|
|
||||||
|
// Save PDF
|
||||||
|
doc.save(`EDR-Voucher-${booking.bookingRef}.pdf`);
|
||||||
|
};
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
import { createRequire } from "module";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
@@ -90,3 +87,4 @@ export default {
|
|||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const flatResponseModules: string[] = [
|
export const flatResponseModules: string[] = [
|
||||||
"/api/file-settings",
|
"/api/file-settings",
|
||||||
|
"api/me",
|
||||||
"/api/auth",
|
"/api/auth",
|
||||||
"/api/sessions",
|
"/api/sessions",
|
||||||
"/api/users",
|
"/api/users",
|
||||||
|
|||||||
50
pnpm-lock.yaml
generated
50
pnpm-lock.yaml
generated
@@ -656,6 +656,9 @@ importers:
|
|||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.59.0
|
specifier: ^5.59.0
|
||||||
version: 5.101.0(react@18.3.1)
|
version: 5.101.0(react@18.3.1)
|
||||||
|
'@types/qrcode':
|
||||||
|
specifier: ^1.5.6
|
||||||
|
version: 1.5.6
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.7.7
|
specifier: ^1.7.7
|
||||||
version: 1.17.0
|
version: 1.17.0
|
||||||
@@ -665,12 +668,21 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
|
jspdf:
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1
|
||||||
|
jspdf-autotable:
|
||||||
|
specifier: ^5.0.8
|
||||||
|
version: 5.0.8(jspdf@4.2.1)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.446.0
|
specifier: ^0.446.0
|
||||||
version: 0.446.0(react@18.3.1)
|
version: 0.446.0(react@18.3.1)
|
||||||
next:
|
next:
|
||||||
specifier: ^14.2.0
|
specifier: ^14.2.0
|
||||||
version: 14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
qrcode.react:
|
qrcode.react:
|
||||||
specifier: ^3.1.0
|
specifier: ^3.1.0
|
||||||
version: 3.2.0(react@18.3.1)
|
version: 3.2.0(react@18.3.1)
|
||||||
@@ -7786,9 +7798,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||||
engines: {node: '>=12', npm: '>=6'}
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
||||||
|
jspdf-autotable@5.0.8:
|
||||||
|
resolution: {integrity: sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==}
|
||||||
|
peerDependencies:
|
||||||
|
jspdf: ^2 || ^3 || ^4
|
||||||
|
|
||||||
jspdf@3.0.4:
|
jspdf@3.0.4:
|
||||||
resolution: {integrity: sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==}
|
resolution: {integrity: sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==}
|
||||||
|
|
||||||
|
jspdf@4.2.1:
|
||||||
|
resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
|
||||||
|
|
||||||
|
jsprim@1.4.2:
|
||||||
|
resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==}
|
||||||
|
engines: {node: '>=0.6.0'}
|
||||||
|
|
||||||
jsx-ast-utils@3.3.5:
|
jsx-ast-utils@3.3.5:
|
||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
@@ -19797,6 +19821,10 @@ snapshots:
|
|||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
semver: 7.8.2
|
semver: 7.8.2
|
||||||
|
|
||||||
|
jspdf-autotable@5.0.8(jspdf@4.2.1):
|
||||||
|
dependencies:
|
||||||
|
jspdf: 4.2.1
|
||||||
|
|
||||||
jspdf@3.0.4:
|
jspdf@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
@@ -19808,6 +19836,28 @@ snapshots:
|
|||||||
dompurify: 3.4.8
|
dompurify: 3.4.8
|
||||||
html2canvas: 1.4.1
|
html2canvas: 1.4.1
|
||||||
|
|
||||||
|
jspdf@4.2.1:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
fast-png: 6.4.0
|
||||||
|
fflate: 0.8.3
|
||||||
|
optionalDependencies:
|
||||||
|
canvg: 3.0.11
|
||||||
|
core-js: 3.49.0
|
||||||
|
dompurify: 3.4.8
|
||||||
|
html2canvas: 1.4.1
|
||||||
|
|
||||||
|
jsprim@1.4.2:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
fast-png: 6.4.0
|
||||||
|
fflate: 0.8.3
|
||||||
|
optionalDependencies:
|
||||||
|
canvg: 3.0.11
|
||||||
|
core-js: 3.49.0
|
||||||
|
dompurify: 3.4.8
|
||||||
|
html2canvas: 1.4.1
|
||||||
|
|
||||||
jsx-ast-utils@3.3.5:
|
jsx-ast-utils@3.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
|
|||||||
Reference in New Issue
Block a user