mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Merge branch 'freight/develop' of https://github.com/Tria-plc/edr-platform into feature/trains-management
This commit is contained in:
@@ -5,9 +5,19 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true,
|
"deleteOutDir": true,
|
||||||
"assets": [
|
"assets": [
|
||||||
{ "include": "migrations/**/*", "outDir": "dist" },
|
{
|
||||||
{ "include": "contracts/templates/**/*", "watchAssets": true }
|
"include": "migrations/**/*",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": "contracts/templates/**/*",
|
||||||
|
"watchAssets": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": "modules/payment/templates/**/*",
|
||||||
|
"watchAssets": true
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"watchAssets": true
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,48 +28,50 @@ export class CreatePaymentTable1780639311366 implements MigrationInterface {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
CREATE TABLE freight.payments (
|
CREATE TABLE freight.payments (
|
||||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
ref_id varchar(255) NOT NULL,
|
ref_id varchar(255) NOT NULL,
|
||||||
|
|
||||||
type freight.payments_type_enum NOT NULL,
|
type freight.payments_type_enum NOT NULL,
|
||||||
|
|
||||||
method freight.payments_method_enum NOT NULL,
|
method freight.payments_method_enum NOT NULL,
|
||||||
|
|
||||||
currency freight.payments_currency_enum NOT NULL,
|
currency freight.payments_currency_enum NOT NULL,
|
||||||
|
|
||||||
amount numeric NOT NULL,
|
amount numeric NOT NULL,
|
||||||
|
|
||||||
raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb,
|
raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
client_action json,
|
client_action json,
|
||||||
|
|
||||||
merchant_order_id varchar(255) NOT NULL,
|
merchant_order_id varchar(255) NOT NULL,
|
||||||
|
|
||||||
transaction_id varchar(255),
|
transaction_id varchar(255),
|
||||||
|
|
||||||
status freight.payments_status_enum NOT NULL DEFAULT 'action-required',
|
status freight.payments_status_enum NOT NULL DEFAULT 'action-required',
|
||||||
|
|
||||||
paid_at date,
|
paid_at date,
|
||||||
|
|
||||||
refunded_at date,
|
refunded_at date,
|
||||||
|
|
||||||
expires_at date,
|
expires_at date,
|
||||||
|
|
||||||
failer_code varchar(30),
|
failer_code varchar(30),
|
||||||
|
|
||||||
failer_message varchar(255),
|
failer_message varchar(255),
|
||||||
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
reason varchar(255),
|
||||||
|
|
||||||
CONSTRAINT PK_payments PRIMARY KEY (id),
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
CONSTRAINT UQ_payments_merchant_order_id UNIQUE (merchant_order_id),
|
CONSTRAINT PK_payments PRIMARY KEY (id),
|
||||||
|
|
||||||
CONSTRAINT UQ_payments_transaction_id UNIQUE (transaction_id)
|
CONSTRAINT UQ_payments_merchant_order_id UNIQUE (merchant_order_id),
|
||||||
);
|
|
||||||
`);
|
CONSTRAINT UQ_payments_transaction_id UNIQUE (transaction_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export class PaymentEntity extends BaseEntity {
|
|||||||
@Column({ type: "numeric" })
|
@Column({ type: "numeric" })
|
||||||
amount!: number
|
amount!: number
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 255, name: "reason", })
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
@Column({ type: "jsonb", default: {}, name: "raw_initiation" })
|
@Column({ type: "jsonb", default: {}, name: "raw_initiation" })
|
||||||
rawInitiation?: Record<string, unknown>
|
rawInitiation?: Record<string, unknown>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
|
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { Response } from "express"
|
import { Response } from "express"
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import Handlebars from 'handlebars';
|
||||||
|
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Controller("payments")
|
@Controller("payments")
|
||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(private readonly paymentService: PaymentService) { }
|
constructor(private readonly paymentService: PaymentService) { }
|
||||||
|
|
||||||
|
|
||||||
|
@Get("/receipts/:orderId/html")
|
||||||
|
async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
|
||||||
|
const filled = await this.paymentService.genReceiptHtml(orderId);
|
||||||
|
return res.send(filled)
|
||||||
|
}
|
||||||
|
|
||||||
@Post("/initiate")
|
@Post("/initiate")
|
||||||
async initiatePayment() {
|
async initiatePayment() {
|
||||||
|
|
||||||
//Only for testing..
|
//Only for testing..
|
||||||
const data = await this.paymentService.pay(20, "ETB", "telebirr", (_) => {
|
const redirectBaseURL = "http://localhost:3004/payment"
|
||||||
|
const description = "booking"
|
||||||
|
const data = await this.paymentService.pay(redirectBaseURL, 20, "ETB", "telebirr", description, (_) => {
|
||||||
return new Promise((resp, _) => {
|
return new Promise((resp, _) => {
|
||||||
resp({
|
resp({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -31,6 +46,7 @@ export class PaymentController {
|
|||||||
if (!payment) {
|
if (!payment) {
|
||||||
throw new NotFoundException('payment not found')
|
throw new NotFoundException('payment not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.send(`
|
return res.send(`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -49,5 +65,25 @@ export class PaymentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// @Get("test")
|
||||||
|
// handleTest(@Res() res: Response) {
|
||||||
|
|
||||||
|
|
||||||
|
// const filePath = path.join(__dirname, "templates", "payment.hbs");
|
||||||
|
// console.log(filePath)
|
||||||
|
// console.log(__dirname)
|
||||||
|
// if (fs.existsSync(filePath)) {
|
||||||
|
// const source = fs.readFileSync(filePath, "utf8");
|
||||||
|
// const template = Handlebars.compile(source);
|
||||||
|
|
||||||
|
// const html = template({
|
||||||
|
// url: "https://example.com"
|
||||||
|
// });
|
||||||
|
|
||||||
|
// res.send(html)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,10 @@ import { ConfigModule } from "@nestjs/config";
|
|||||||
import { PaymentRepository } from "./payment.repository";
|
import { PaymentRepository } from "./payment.repository";
|
||||||
import { WebhookController } from "./webhooks/webhook.controller";
|
import { WebhookController } from "./webhooks/webhook.controller";
|
||||||
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
|
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
|
||||||
|
import { BookingsModule } from "../bookings/bookings.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HttpModule, ConfigModule],
|
imports: [HttpModule, ConfigModule, BookingsModule],
|
||||||
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
|
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
|
||||||
controllers: [PaymentController, WebhookController]
|
controllers: [PaymentController, WebhookController]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class PaymentRepository {
|
|||||||
this.paymentRepo = this.dataSource.getRepository(PaymentEntity)
|
this.paymentRepo = this.dataSource.getRepository(PaymentEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTr(qr: QueryRunner, data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt">): Promise<PaymentEntity> {
|
async createTr(qr: QueryRunner, data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt" | "reason">): Promise<PaymentEntity> {
|
||||||
const payment = qr.manager.create(PaymentEntity, data)
|
const payment = qr.manager.create(PaymentEntity, data)
|
||||||
return qr.manager.save(payment)
|
return qr.manager.save(payment)
|
||||||
}
|
}
|
||||||
@@ -35,5 +35,5 @@ export class PaymentRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
|
||||||
import { DataSource, QueryRunner } from "typeorm";
|
import { DataSource, QueryRunner } from "typeorm";
|
||||||
import { PaymentEntity } from "./entities/payment.entity";
|
import { PaymentEntity } from "./entities/payment.entity";
|
||||||
import { PaymentStrategy } from "./strategies/payment.strategy";
|
import { PaymentStrategy } from "./strategies/payment.strategy";
|
||||||
@@ -6,7 +6,11 @@ import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy"
|
|||||||
import { PaymentRepository } from "./payment.repository";
|
import { PaymentRepository } from "./payment.repository";
|
||||||
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
|
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { PaymentStatus, UpdatePaymentStatusDto } from "./dto/update-payment-status.dto";
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as Handlebars from 'handlebars';
|
||||||
|
|
||||||
|
|
||||||
type PaymentMethod = PaymentEntity["method"]
|
type PaymentMethod = PaymentEntity["method"]
|
||||||
type CurrencyType = PaymentEntity["currency"]
|
type CurrencyType = PaymentEntity["currency"]
|
||||||
@@ -24,7 +28,7 @@ export class PaymentService {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
async pay(amount: number, currency: CurrencyType, method: PaymentMethod, cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{
|
async pay(redirectBaseURL: string, amount: number, currency: CurrencyType, method: PaymentMethod, reason: string, cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{
|
||||||
refId: string,
|
refId: string,
|
||||||
clientAction: ClientAction,
|
clientAction: ClientAction,
|
||||||
status: PaymentEntity["status"],
|
status: PaymentEntity["status"],
|
||||||
@@ -33,6 +37,7 @@ export class PaymentService {
|
|||||||
failureMessage?: string,
|
failureMessage?: string,
|
||||||
}> {
|
}> {
|
||||||
|
|
||||||
|
|
||||||
const strategy = this.strategies.get(method)
|
const strategy = this.strategies.get(method)
|
||||||
if (!strategy) {
|
if (!strategy) {
|
||||||
throw new NotFoundException("strategy not found")
|
throw new NotFoundException("strategy not found")
|
||||||
@@ -40,6 +45,7 @@ export class PaymentService {
|
|||||||
|
|
||||||
const orderId = `freigh${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic
|
const orderId = `freigh${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic
|
||||||
const paymentResp = await strategy.pay({
|
const paymentResp = await strategy.pay({
|
||||||
|
redirectBaseURL,
|
||||||
amountMinor: amount,
|
amountMinor: amount,
|
||||||
currency: currency,
|
currency: currency,
|
||||||
merchantOrderId: orderId,
|
merchantOrderId: orderId,
|
||||||
@@ -62,7 +68,8 @@ export class PaymentService {
|
|||||||
merchantOrderId: orderId,
|
merchantOrderId: orderId,
|
||||||
rawInitiation: paymentResp.rawInitiation,
|
rawInitiation: paymentResp.rawInitiation,
|
||||||
clientAction: paymentResp.clientAction,
|
clientAction: paymentResp.clientAction,
|
||||||
expiresAt: paymentResp.expiresAt
|
expiresAt: paymentResp.expiresAt,
|
||||||
|
reason
|
||||||
|
|
||||||
})
|
})
|
||||||
await queryRunner.commitTransaction()
|
await queryRunner.commitTransaction()
|
||||||
@@ -88,26 +95,47 @@ export class PaymentService {
|
|||||||
return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method)
|
return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async genReceiptHtml(orderId: string) {
|
||||||
async handleTelebirrPaymentCb(dto: UpdatePaymentStatusDto): Promise<void> {
|
const payment = await this.paymentRepo.findOneBy({
|
||||||
switch (dto.status) {
|
merchantOrderId: orderId,
|
||||||
case PaymentStatus.SUCCEEDED:
|
status: "success"
|
||||||
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "success" })
|
})
|
||||||
break;
|
if (!payment) {
|
||||||
case PaymentStatus.FAILED:
|
throw new BadRequestException()
|
||||||
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "failed", failureMessage: dto.failureMessage })
|
|
||||||
break;
|
|
||||||
case PaymentStatus.CANCELLED:
|
|
||||||
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
|
|
||||||
break;
|
|
||||||
case PaymentStatus.PROCESSING:
|
|
||||||
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
|
|
||||||
break;
|
|
||||||
case PaymentStatus.REFUNDED:
|
|
||||||
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
|
|
||||||
break;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(__dirname, "templates", "ceiepts.hbs");
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new InternalServerErrorException()
|
||||||
|
}
|
||||||
|
const source = fs.readFileSync(filePath, "utf8");
|
||||||
|
const template = Handlebars.compile(source);
|
||||||
|
|
||||||
|
const html = template({
|
||||||
|
vendorName: "Ethio Djibouti Railway Ticket Booking",
|
||||||
|
vendorAddress: "Addis Ababa",
|
||||||
|
receiptDate: payment.paidAt,
|
||||||
|
paymentMethod: payment?.method,
|
||||||
|
subtotal: payment?.amount.toString(),
|
||||||
|
total: payment?.amount.toString(),
|
||||||
|
currency: payment?.currency,
|
||||||
|
reason: payment?.reason
|
||||||
|
});
|
||||||
|
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
// const templatePath = path.join(
|
||||||
|
// process.cwd(),
|
||||||
|
// 'src/modules/payment/templates/receipt.hbs',
|
||||||
|
// );
|
||||||
|
|
||||||
|
|
||||||
|
// console.log(templatePath)
|
||||||
|
|
||||||
|
// const source = fs.readFileSync(templatePath, 'utf8');
|
||||||
|
// const template = Handlebars.compile(source);
|
||||||
|
// return this.getReceiptTemplate();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
|
|||||||
async pay(data: ProviderInitiationInput): Promise<any> {
|
async pay(data: ProviderInitiationInput): Promise<any> {
|
||||||
// const refId = randomUUID()
|
// const refId = randomUUID()
|
||||||
// const orderId = createMerchantOrderId()
|
// const orderId = createMerchantOrderId()
|
||||||
const resp = await this.initiate(data)
|
const redirectURL = `${data.redirectBaseURL}/${data.merchantOrderId}`
|
||||||
|
const resp = await this.initiate(redirectURL, data)
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,9 +45,9 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
async initiate(redirectURL: string, input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||||
const fabricToken = await this.applyFabricToken();
|
const fabricToken = await this.applyFabricToken();
|
||||||
const requestBody = this.buildCreateOrderRequest(input);
|
const requestBody = this.buildCreateOrderRequest(redirectURL, input);
|
||||||
const response = await this.requestCreateOrder(fabricToken, requestBody);
|
const response = await this.requestCreateOrder(fabricToken, requestBody);
|
||||||
|
|
||||||
const prepayId = response.biz_content?.prepay_id;
|
const prepayId = response.biz_content?.prepay_id;
|
||||||
@@ -176,8 +177,9 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
|
private buildCreateOrderRequest(redirectURL: string, input: ProviderInitiationInput): CreateOrderRequest {
|
||||||
const totalAmount = String(input.amountMinor / 100);
|
// const totalAmount = String(input.amountMinor / 100);
|
||||||
|
const totalAmount = String(input.amountMinor)
|
||||||
const req = {
|
const req = {
|
||||||
timestamp: createTimestamp(),
|
timestamp: createTimestamp(),
|
||||||
nonce_str: createNonceStr(),
|
nonce_str: createNonceStr(),
|
||||||
@@ -186,6 +188,7 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
|
|||||||
biz_content: {
|
biz_content: {
|
||||||
notify_url: this.notifyUrl,
|
notify_url: this.notifyUrl,
|
||||||
appid: this.merchantAppId,
|
appid: this.merchantAppId,
|
||||||
|
redirect_url: redirectURL,
|
||||||
merch_code: this.merchantCode,
|
merch_code: this.merchantCode,
|
||||||
merch_order_id: input.merchantOrderId,
|
merch_order_id: input.merchantOrderId,
|
||||||
trade_type: 'Checkout' as const,
|
trade_type: 'Checkout' as const,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type ClientAction =
|
|||||||
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
|
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
|
||||||
|
|
||||||
export interface ProviderInitiationInput {
|
export interface ProviderInitiationInput {
|
||||||
|
redirectBaseURL: string;
|
||||||
merchantOrderId: string;
|
merchantOrderId: string;
|
||||||
// bookingRef: string;
|
// bookingRef: string;
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Redirecting...</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Redirecting...</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.location.href = "{{url}}";
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
191
apps/edr-freight-api/src/modules/payment/templates/receipt.hbs
Normal file
191
apps/edr-freight-api/src/modules/payment/templates/receipt.hbs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Receipt - {{vendorName}}</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-box {
|
||||||
|
max-width: 550px;
|
||||||
|
margin: auto;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 2px dashed #eee;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin: 0 0 5px;
|
||||||
|
color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #777;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td {
|
||||||
|
padding: 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #777;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-section {
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-container {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
background: #0056b3;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
background: #004494;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-box {
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="receipt-box">
|
||||||
|
<div class="header">
|
||||||
|
<h1>{{vendorName}}</h1>
|
||||||
|
<p>{{vendorAddress}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="details-table">
|
||||||
|
<tr>
|
||||||
|
<td class="label">Date</td>
|
||||||
|
<td class="value">{{receiptDate}}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="label">Payment Method</td>
|
||||||
|
<td class="value" style="text-transform: capitalize;">
|
||||||
|
{{paymentMethod}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="label">Description</td>
|
||||||
|
<td class="value">{{reason}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="totals-section">
|
||||||
|
<table class="details-table">
|
||||||
|
<tr>
|
||||||
|
<td class="label">Subtotal</td>
|
||||||
|
<td class="value">
|
||||||
|
{{currency}} {{subtotal}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr class="total-row">
|
||||||
|
<td class="label" style="color:#333">
|
||||||
|
Total Paid
|
||||||
|
</td>
|
||||||
|
<td class="value" style="color:#0056b3">
|
||||||
|
{{currency}} {{total}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Thank you for traveling with us!</p>
|
||||||
|
<p>Have a safe journey.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-container">
|
||||||
|
<button class="download-btn" onclick="downloadPdf()">
|
||||||
|
Download PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function downloadPdf() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,13 +1,48 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
export class TelebirrDto {
|
export class TelebirrDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
merch_order_id!: string;
|
merch_order_id!: string;
|
||||||
|
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
payment_order_id!: string;
|
payment_order_id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ default: "SUCCEEDED"})
|
||||||
|
@IsString()
|
||||||
trade_status!: string;
|
trade_status!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
trans_id?: string;
|
trans_id?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
total_amount?: string;
|
total_amount?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
trans_currency?: string;
|
trans_currency?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
notify_time?: string;
|
notify_time?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
trans_end_time?: string;
|
trans_end_time?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
sign!: string;
|
sign!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
sign_type?: string;
|
sign_type?: string;
|
||||||
|
|
||||||
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,17 @@ import { Injectable, } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as crypto from "crypto"
|
import * as crypto from "crypto"
|
||||||
import { TelebirrDto } from '../dto/telebirr.dto';
|
import { TelebirrDto } from '../dto/telebirr.dto';
|
||||||
|
import { BookingsRepository } from 'src/modules/bookings/bookings.repository';
|
||||||
|
import { PaymentRepository } from '../../payment.repository';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TelebirrWebhookService {
|
export class TelebirrWebhookService {
|
||||||
// private readonly logger = new Logger(TelebirrWebhookService.name);
|
// private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService
|
private readonly config: ConfigService,
|
||||||
|
private readonly bookingRepo: BookingsRepository,
|
||||||
|
private readonly paymentRepo: PaymentRepository,
|
||||||
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
verifyTelebirrNotification(payload: TelebirrDto) {
|
verifyTelebirrNotification(payload: TelebirrDto) {
|
||||||
@@ -43,7 +48,33 @@ export class TelebirrWebhookService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handle(payload: TelebirrDto): Promise<void> {
|
async handle(payload: TelebirrDto): Promise<void> {
|
||||||
console.log(payload)
|
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
|
||||||
|
if (!payment) {
|
||||||
|
throw new Error("payment not found")
|
||||||
|
}
|
||||||
|
switch (payload.trade_status) {
|
||||||
|
case "SUCCEEDED":
|
||||||
|
await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() })
|
||||||
|
switch (payment.type) {
|
||||||
|
case "booking":
|
||||||
|
await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", })
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "FAILED":
|
||||||
|
await this.paymentRepo.update({ id: payment.id }, { status: "failed" })
|
||||||
|
break;
|
||||||
|
case "CANCELLED":
|
||||||
|
await this.paymentRepo.update({ id: payment.id }, { status: "canceled" })
|
||||||
|
break;
|
||||||
|
case "PROCESSING":
|
||||||
|
await this.paymentRepo.update({ id: payment.id }, { status: "processing" })
|
||||||
|
break;
|
||||||
|
case "REFUNDED":
|
||||||
|
await this.paymentRepo.update({ id: payment.id }, { status: "refunded" })
|
||||||
|
break;
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { All, Body, Controller, HttpCode, HttpStatus, Logger, } from '@nestjs/common';
|
import { Body, Controller, HttpCode, HttpStatus, Logger, Post, } from '@nestjs/common';
|
||||||
import { TelebirrWebhookService } from './providers/telebirr.service';
|
import { TelebirrWebhookService } from './providers/telebirr.service';
|
||||||
import { ApiOperation } from '@nestjs/swagger';
|
import { ApiOperation } from '@nestjs/swagger';
|
||||||
import { TelebirrDto } from './dto/telebirr.dto';
|
import { TelebirrDto } from './dto/telebirr.dto';
|
||||||
|
import { Public } from '@edr/api-common';
|
||||||
|
|
||||||
@Controller("payments/webhooks")
|
@Controller("payments-webhooks")
|
||||||
|
@Public()
|
||||||
export class WebhookController {
|
export class WebhookController {
|
||||||
constructor(private readonly telebirr: TelebirrWebhookService) { }
|
constructor(private readonly telebirr: TelebirrWebhookService) { }
|
||||||
private readonly logger = new Logger(WebhookController.name);
|
private readonly logger = new Logger(WebhookController.name);
|
||||||
|
|
||||||
@All('telebirr')
|
@Post('telebirr')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Telebirr payment notification callback (Ethiopia)',
|
summary: 'Telebirr payment notification callback (Ethiopia)',
|
||||||
@@ -20,16 +22,13 @@ export class WebhookController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const verified = this.telebirr.verifyTelebirrNotification(payload)
|
// const verified = this.telebirr.verifyTelebirrNotification(payload)
|
||||||
if (!verified) {
|
// if (!verified) {
|
||||||
throw new Error("not valid")
|
// throw new Error("not valid")
|
||||||
}
|
// }
|
||||||
const merchantOrderId = payload.merch_order_id;
|
// const merchantOrderId = payload.merch_order_id;
|
||||||
if (merchantOrderId.startsWith("freight")) {
|
await this.telebirr.handle(payload);
|
||||||
await this.telebirr.handle(payload);
|
|
||||||
} else if (merchantOrderId.startsWith("passagner")) {
|
|
||||||
//todo: handle else where
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Building2,
|
Building2,
|
||||||
@@ -7,6 +9,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Eye,
|
Eye,
|
||||||
|
LoaderCircle,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
@@ -20,14 +23,12 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getCurrentCustomer,
|
getCurrentCustomer,
|
||||||
getMyBookings,
|
|
||||||
getMyInvoices,
|
getMyInvoices,
|
||||||
getMyShipments,
|
getMyShipments,
|
||||||
} from "@/lib/currentCustomer";
|
} from "@/lib/currentCustomer";
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||||
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
|
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
|
||||||
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||||
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -36,16 +37,36 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = [
|
||||||
|
"DRAFT",
|
||||||
|
"SUBMITTED",
|
||||||
|
"PENDING_APPROVAL",
|
||||||
|
"IN_TRANSIT",
|
||||||
|
];
|
||||||
|
|
||||||
export default function MyPortalPage() {
|
export default function MyPortalPage() {
|
||||||
const me = useMemo(() => getCurrentCustomer(), []);
|
const me = useMemo(() => getCurrentCustomer(), []);
|
||||||
const myBookings = useMemo(() => getMyBookings(), []);
|
|
||||||
const myShipments = useMemo(() => getMyShipments(), []);
|
const myShipments = useMemo(() => getMyShipments(), []);
|
||||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||||
|
|
||||||
const activeBookings = myBookings.filter(
|
const navigate = useNavigate();
|
||||||
(b) => b.status === "Confirmed" || b.status === "In Transit",
|
|
||||||
|
const bookingsQuery = useQuery(
|
||||||
|
api.bookings.list.queryOptions({
|
||||||
|
input: { sortBy: "createdAt", sortOrder: "DESC" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const myBookings = useMemo(
|
||||||
|
() =>
|
||||||
|
(bookingsQuery.data?.items ?? []).filter((b) =>
|
||||||
|
ACTIVE_STATUSES.includes(b.status),
|
||||||
|
),
|
||||||
|
[bookingsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
|
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
|
||||||
const outstandingInvoices = myInvoices.filter(
|
const outstandingInvoices = myInvoices.filter(
|
||||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||||
@@ -58,7 +79,7 @@ export default function MyPortalPage() {
|
|||||||
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
|
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
|
||||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
|
||||||
const recentBookings = [...myBookings].slice(0, 5);
|
const recentBookings = myBookings.slice(0, 5);
|
||||||
const recentInvoices = [...myInvoices].slice(0, 4);
|
const recentInvoices = [...myInvoices].slice(0, 4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -117,7 +138,7 @@ export default function MyPortalPage() {
|
|||||||
<Link to="/bookings/new">
|
<Link to="/bookings/new">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="bg-white text-[#10B981] hover:bg-slate-100"
|
className="bg-white text-[#10B981] hover:bg-muted"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New Booking
|
New Booking
|
||||||
@@ -156,7 +177,7 @@ export default function MyPortalPage() {
|
|||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activeShipments.length === 0 ? (
|
{activeShipments.length === 0 ? (
|
||||||
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
<p className="rounded-2xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||||
No shipments currently in transit.
|
No shipments currently in transit.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -164,27 +185,27 @@ export default function MyPortalPage() {
|
|||||||
{activeShipments.slice(0, 4).map((shipment) => (
|
{activeShipments.slice(0, 4).map((shipment) => (
|
||||||
<div
|
<div
|
||||||
key={shipment.id}
|
key={shipment.id}
|
||||||
className="rounded-2xl border border-slate-100 p-4 transition hover:border-primary/20 hover:bg-primary/5"
|
className="rounded-2xl border border-border p-4 transition hover:border-primary/20 hover:bg-primary/5"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-semibold text-slate-900">
|
<span className="font-semibold text-foreground">
|
||||||
{shipment.reference}
|
{shipment.reference}
|
||||||
</span>
|
</span>
|
||||||
<ShipmentBadge status={shipment.status} />
|
<ShipmentBadge status={shipment.status} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-slate-700">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{shipment.originStation}
|
{shipment.originStation}
|
||||||
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
|
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
|
||||||
{shipment.destinationStation}
|
{shipment.destinationStation}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<MapPin className="h-3 w-3 text-primary" />
|
<MapPin className="h-3 w-3 text-primary" />
|
||||||
{shipment.currentLocation}
|
{shipment.currentLocation}
|
||||||
</span>
|
</span>
|
||||||
<span>ETA {shipment.eta}</span>
|
<span>ETA {shipment.eta}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-primary transition-all"
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
style={{ width: `${shipment.progress}%` }}
|
style={{ width: `${shipment.progress}%` }}
|
||||||
@@ -215,50 +236,43 @@ export default function MyPortalPage() {
|
|||||||
|
|
||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
{recentBookings.length === 0 ? (
|
{recentBookings.length === 0 ? (
|
||||||
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
<p className="rounded-2xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||||
You haven't booked any freight yet.
|
You haven't booked any freight yet.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
|
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
|
||||||
<thead className="text-xs text-slate-500">
|
<thead className="text-xs text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-2 font-medium">Reference</th>
|
<th className="px-6 py-2 font-medium">Reference</th>
|
||||||
<th className="py-2 font-medium">Route</th>
|
<th className="py-2 font-medium">Route</th>
|
||||||
<th className="py-2 font-medium">Cargo</th>
|
<th className="py-2 font-medium">Cargo</th>
|
||||||
|
<th className="py-2 font-medium">Date</th>
|
||||||
<th className="py-2 font-medium">Status</th>
|
<th className="py-2 font-medium">Status</th>
|
||||||
<th className="px-6 py-2 text-right font-medium">
|
|
||||||
Action
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recentBookings.map((booking) => (
|
{recentBookings.map((booking) => (
|
||||||
<tr
|
<tr
|
||||||
key={booking.id}
|
key={booking.id}
|
||||||
className="border-t border-slate-100 transition hover:bg-primary/5"
|
className="border-t border-border transition hover:bg-primary/5 cursor-pointer"
|
||||||
|
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||||
>
|
>
|
||||||
<td className="px-6 py-3 font-medium text-slate-900">
|
<td className="px-6 py-3 font-medium text-foreground">
|
||||||
{booking.reference}
|
{booking.reference}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 text-slate-700">
|
<td className="py-3 text-muted-foreground">
|
||||||
{booking.originStation} → {booking.destinationStation}
|
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} → {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 text-slate-700">
|
<td className="py-3 text-muted-foreground">
|
||||||
{booking.cargoType}
|
{booking.freightType === "CONTAINER" ? "Container" : booking.freightType}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-muted-foreground">
|
||||||
|
{format(new Date(booking.createdAt), "MMM d, yyyy HH:mm")}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3">
|
<td className="py-3">
|
||||||
<BookingBadge status={booking.status} />
|
<BookingBadge status={booking.status} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-right">
|
|
||||||
<Link
|
|
||||||
to={`/bookings/${booking.id}`}
|
|
||||||
aria-label="View booking"
|
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 transition hover:bg-primary/10 hover:text-primary"
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -289,7 +303,7 @@ export default function MyPortalPage() {
|
|||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{recentInvoices.length === 0 ? (
|
{recentInvoices.length === 0 ? (
|
||||||
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
<p className="rounded-2xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||||
No invoices yet.
|
No invoices yet.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -297,16 +311,16 @@ export default function MyPortalPage() {
|
|||||||
{recentInvoices.map((invoice) => (
|
{recentInvoices.map((invoice) => (
|
||||||
<div
|
<div
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
className="rounded-2xl border border-slate-100 p-4 transition hover:border-primary/20 hover:bg-primary/5"
|
className="rounded-2xl border border-border p-4 transition hover:border-primary/20 hover:bg-primary/5"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Receipt className="h-4 w-4 text-primary" />
|
<Receipt className="h-4 w-4 text-primary" />
|
||||||
<InvoiceBadge status={invoice.status} />
|
<InvoiceBadge status={invoice.status} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 pt-2 text-lg font-bold text-slate-900">
|
<p className="mt-0.5 pt-2 text-lg font-bold text-foreground">
|
||||||
{formatCurrency(invoice.amount, invoice.currency)}
|
{formatCurrency(invoice.amount, invoice.currency)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500">
|
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Due {invoice.dueDate}
|
Due {invoice.dueDate}
|
||||||
</p>
|
</p>
|
||||||
@@ -334,8 +348,8 @@ function ProfileRow({
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5 text-primary">{icon}</div>
|
<div className="mt-0.5 text-primary">{icon}</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-xs font-medium text-slate-500">{label}</p>
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
<p className="mt-0.5 text-sm text-foreground">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -343,9 +357,9 @@ function ProfileRow({
|
|||||||
|
|
||||||
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
|
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
|
||||||
const styles: Record<ShipmentStatus, string> = {
|
const styles: Record<ShipmentStatus, string> = {
|
||||||
"In Transit": "bg-indigo-100 text-indigo-700",
|
"In Transit": "bg-muted text-foreground",
|
||||||
Delivered: "bg-emerald-100 text-emerald-700",
|
Delivered: "bg-primary/10 text-primary",
|
||||||
Delayed: "bg-red-100 text-red-700",
|
Delayed: "bg-destructive/10 text-destructive",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -356,29 +370,31 @@ function ShipmentBadge({ status }: { status: ShipmentStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookingBadge({ status }: { status: BookingStatus }) {
|
function BookingBadge({ status }: { status: string }) {
|
||||||
const styles: Record<BookingStatus, string> = {
|
const styles: Record<string, string> = {
|
||||||
Pending: "bg-amber-100 text-amber-700",
|
DRAFT: "bg-amber-100 text-amber-700",
|
||||||
Confirmed: "bg-sky-100 text-sky-700",
|
SUBMITTED: "bg-primary/10 text-primary",
|
||||||
"In Transit": "bg-indigo-100 text-indigo-700",
|
PENDING_APPROVAL: "bg-muted text-foreground",
|
||||||
Delivered: "bg-emerald-100 text-emerald-700",
|
IN_TRANSIT: "bg-muted text-foreground",
|
||||||
Cancelled: "bg-red-100 text-red-700",
|
COMPLETED: "bg-primary/10 text-primary",
|
||||||
|
CANCELLED: "bg-destructive/10 text-destructive",
|
||||||
|
REJECTED: "bg-destructive/10 text-destructive",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status] || "bg-muted text-muted-foreground"}`}
|
||||||
>
|
>
|
||||||
{status}
|
{status.replace(/_/g, " ")}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
|
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
|
||||||
const styles: Record<InvoiceStatus, string> = {
|
const styles: Record<InvoiceStatus, string> = {
|
||||||
Draft: "bg-slate-100 text-slate-600",
|
Draft: "bg-muted text-muted-foreground",
|
||||||
Sent: "bg-sky-100 text-sky-700",
|
Sent: "bg-primary/10 text-primary",
|
||||||
Paid: "bg-emerald-100 text-emerald-700",
|
Paid: "bg-primary/10 text-primary",
|
||||||
Overdue: "bg-red-100 text-red-700",
|
Overdue: "bg-destructive/10 text-destructive",
|
||||||
Cancelled: "bg-amber-100 text-amber-700",
|
Cancelled: "bg-amber-100 text-amber-700",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
FileUp,
|
FileUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
@@ -57,10 +59,11 @@ import { cn } from "@/lib/utils";
|
|||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
|
||||||
const PROGRESS_STAGES = [
|
const PROGRESS_STAGES = [
|
||||||
{ label: "Request", icon: FileText, statuses: ["DRAFT"] },
|
{ label: "Request", icon: FileText, statuses: ["DRAFT", "CHANGES_REQUESTED"] },
|
||||||
{ label: "Approval", icon: ClipboardCheck, statuses: ["CONFIRMED"] },
|
{ label: "Submitted", icon: ClipboardCheck, statuses: ["SUBMITTED", "PENDING_APPROVAL"] },
|
||||||
{ label: "In Transit", icon: Train, statuses: ["IN_TRANSIT"] },
|
{ label: "Approved", icon: ShieldCheck, statuses: ["APPROVED_PENDING_SIGNATURE", "APPROVED", "CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"] },
|
||||||
{ label: "Complete", icon: PackageCheck, statuses: ["DELIVERED"] },
|
{ label: "In Transit", icon: Train, statuses: ["PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS", "PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] },
|
||||||
|
{ label: "Complete", icon: PackageCheck, statuses: ["COMPLETED", "DELIVERED"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_MAP: Record<
|
const STATUS_MAP: Record<
|
||||||
@@ -70,31 +73,115 @@ const STATUS_MAP: Record<
|
|||||||
DRAFT: {
|
DRAFT: {
|
||||||
title: "Drafting Request",
|
title: "Drafting Request",
|
||||||
description: "Booking is being prepared and has not been submitted.",
|
description: "Booking is being prepared and has not been submitted.",
|
||||||
color: "text-slate-500",
|
color: "text-muted-foreground",
|
||||||
stage: 0,
|
stage: 0,
|
||||||
},
|
},
|
||||||
CONFIRMED: {
|
CHANGES_REQUESTED: {
|
||||||
title: "Booking Confirmed",
|
title: "Changes Requested",
|
||||||
description: "Booking has been confirmed and approved.",
|
description: "Staff has requested changes. Please review and resubmit.",
|
||||||
color: "text-emerald-600",
|
color: "text-amber-600",
|
||||||
|
stage: 0,
|
||||||
|
},
|
||||||
|
SUBMITTED: {
|
||||||
|
title: "Submitted",
|
||||||
|
description: "Your booking has been submitted for review.",
|
||||||
|
color: "text-primary",
|
||||||
stage: 1,
|
stage: 1,
|
||||||
},
|
},
|
||||||
|
PENDING_APPROVAL: {
|
||||||
|
title: "Pending Approval",
|
||||||
|
description: "Booking is in the approval process.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 1,
|
||||||
|
},
|
||||||
|
APPROVED_PENDING_SIGNATURE: {
|
||||||
|
title: "Awaiting Signature",
|
||||||
|
description: "Approved — pending contract signature.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 2,
|
||||||
|
},
|
||||||
|
APPROVED: {
|
||||||
|
title: "Approved",
|
||||||
|
description: "Booking has been fully approved.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 2,
|
||||||
|
},
|
||||||
|
CONTRACT_READY: {
|
||||||
|
title: "Contract Ready",
|
||||||
|
description: "Contract is available for review and signature.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 2,
|
||||||
|
},
|
||||||
|
SIGNED_CUSTOMER: {
|
||||||
|
title: "Customer Signed",
|
||||||
|
description: "Customer has signed. Awaiting staff signature.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 2,
|
||||||
|
},
|
||||||
|
FULLY_EXECUTED: {
|
||||||
|
title: "Fully Executed",
|
||||||
|
description: "Contract has been fully signed and executed.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 2,
|
||||||
|
},
|
||||||
|
PNR_GENERATED: {
|
||||||
|
title: "PNR Generated",
|
||||||
|
description: "Payment reference number generated.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 3,
|
||||||
|
},
|
||||||
|
PAYMENT_VERIFICATION_IN_PROGRESS: {
|
||||||
|
title: "Payment Verification",
|
||||||
|
description: "Payment is being verified.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 3,
|
||||||
|
},
|
||||||
|
PAID: {
|
||||||
|
title: "Paid",
|
||||||
|
description: "Payment has been confirmed.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 3,
|
||||||
|
},
|
||||||
IN_TRANSIT: {
|
IN_TRANSIT: {
|
||||||
title: "Cargo Moving",
|
title: "Cargo Moving",
|
||||||
description: "Shipment is currently moving through the rail network.",
|
description: "Shipment is currently moving through the rail network.",
|
||||||
color: "text-sky-600",
|
color: "text-primary",
|
||||||
stage: 2,
|
stage: 3,
|
||||||
|
},
|
||||||
|
PENDING_CONSOLIDATION: {
|
||||||
|
title: "Pending Consolidation",
|
||||||
|
description: "Awaiting consolidation partner.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 3,
|
||||||
|
},
|
||||||
|
CONSOLIDATED: {
|
||||||
|
title: "Consolidated",
|
||||||
|
description: "Cargo has been consolidated with partner shipment.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 3,
|
||||||
|
},
|
||||||
|
COMPLETED: {
|
||||||
|
title: "Service Complete",
|
||||||
|
description: "Cargo delivered and service successfully terminated.",
|
||||||
|
color: "text-primary",
|
||||||
|
stage: 4,
|
||||||
},
|
},
|
||||||
DELIVERED: {
|
DELIVERED: {
|
||||||
title: "Service Complete",
|
title: "Service Complete",
|
||||||
description: "Cargo delivered and service successfully terminated.",
|
description: "Cargo delivered and service successfully terminated.",
|
||||||
color: "text-emerald-600",
|
color: "text-primary",
|
||||||
stage: 3,
|
stage: 4,
|
||||||
|
},
|
||||||
|
REJECTED: {
|
||||||
|
title: "Rejected",
|
||||||
|
description: "This booking request has been rejected.",
|
||||||
|
color: "text-destructive",
|
||||||
|
stage: -1,
|
||||||
},
|
},
|
||||||
CANCELLED: {
|
CANCELLED: {
|
||||||
title: "Cancelled",
|
title: "Cancelled",
|
||||||
description: "This booking process has been terminated.",
|
description: "This booking process has been terminated.",
|
||||||
color: "text-red-600",
|
color: "text-destructive",
|
||||||
stage: -1,
|
stage: -1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -145,10 +232,10 @@ export default function BookingDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<Card className="flex flex-col items-center p-12 text-center">
|
<Card className="flex flex-col items-center p-12 text-center">
|
||||||
<div className="flex size-16 items-center justify-center rounded-full bg-red-50 text-red-400">
|
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||||
<AlertTriangle className="size-8" />
|
<AlertTriangle className="size-8" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-4 text-2xl font-bold text-slate-900">
|
<h1 className="mt-4 text-2xl font-bold text-foreground">
|
||||||
Failed to load booking
|
Failed to load booking
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
@@ -165,10 +252,10 @@ export default function BookingDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<Card className="flex flex-col items-center p-12 text-center">
|
<Card className="flex flex-col items-center p-12 text-center">
|
||||||
<div className="flex size-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
|
<div className="flex size-16 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
<Package className="size-8" />
|
<Package className="size-8" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-4 text-2xl font-bold text-slate-900">
|
<h1 className="mt-4 text-2xl font-bold text-foreground">
|
||||||
Booking not found
|
Booking not found
|
||||||
</h1>
|
</h1>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -176,7 +263,7 @@ export default function BookingDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.status === "DRAFT") {
|
if (booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") {
|
||||||
return (
|
return (
|
||||||
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
|
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
|
||||||
);
|
);
|
||||||
@@ -196,15 +283,22 @@ function DraftBookingView({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { customer } = useAuth();
|
const { customer } = useAuth();
|
||||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
const documentsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<
|
const [selectedFiles, setSelectedFiles] = useState<
|
||||||
Record<string, File | null>
|
Record<string, File | null>
|
||||||
>({});
|
>({});
|
||||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
const [cancelReason, setCancelReason] = useState("");
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
const [docError, setDocError] = useState("");
|
||||||
|
|
||||||
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
|
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
|
||||||
|
|
||||||
|
const uploadedCodes = useMemo(
|
||||||
|
() => new Set(booking.files?.map((f) => f.code) ?? []),
|
||||||
|
[booking.files],
|
||||||
|
);
|
||||||
|
|
||||||
const pricingQuery = useQuery(
|
const pricingQuery = useQuery(
|
||||||
api.bookings.generatePrice.queryOptions({
|
api.bookings.generatePrice.queryOptions({
|
||||||
input: { id: booking.id },
|
input: { id: booking.id },
|
||||||
@@ -217,6 +311,7 @@ function DraftBookingView({
|
|||||||
api.bookings.uploadDocuments.call({ id: booking.id, files }),
|
api.bookings.uploadDocuments.call({ id: booking.id, files }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSelectedFiles({});
|
setSelectedFiles({});
|
||||||
|
setDocError("");
|
||||||
onBookingUpdated();
|
onBookingUpdated();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -258,8 +353,17 @@ function DraftBookingView({
|
|||||||
cancelMutation.mutate(reason);
|
cancelMutation.mutate(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canConfirm =
|
function handleSubmitRequest() {
|
||||||
pricingQuery.isSuccess && !uploadMutation.isPending && !submitMutation.isPending;
|
const missing = REQUIRED_DOC_FIELDS.filter(
|
||||||
|
(doc) => !uploadedCodes.has(doc.key),
|
||||||
|
);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setDocError("Please upload all required documents before submitting.");
|
||||||
|
documentsRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitMutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
const companyName = (customer as any)?.company?.name ?? "—";
|
const companyName = (customer as any)?.company?.name ?? "—";
|
||||||
const companyTin = (customer as any)?.company?.tin ?? "—";
|
const companyTin = (customer as any)?.company?.tin ?? "—";
|
||||||
@@ -282,7 +386,7 @@ function DraftBookingView({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-slate-200 text-slate-600">
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||||
<Package className="size-6" />
|
<Package className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
@@ -290,22 +394,49 @@ function DraftBookingView({
|
|||||||
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
||||||
{booking.reference}
|
{booking.reference}
|
||||||
</h1>
|
</h1>
|
||||||
<StatusBadge status="DRAFT" />
|
<StatusBadge status={booking.status} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Complete the steps below to submit your booking request.
|
{booking.status === "CHANGES_REQUESTED"
|
||||||
|
? "Staff has requested changes. Please review, update, and resubmit."
|
||||||
|
: "Complete the steps below to submit your booking request."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmitRequest}
|
||||||
|
disabled={submitMutation.isPending}
|
||||||
|
>
|
||||||
|
{submitMutation.isPending ? (
|
||||||
|
<LoaderCircle className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 />
|
||||||
|
)}
|
||||||
|
{submitMutation.isPending
|
||||||
|
? "Submitting..."
|
||||||
|
: "Confirm Booking Request"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{booking.status === "CHANGES_REQUESTED" && booking.latestChangeRequestNote && (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Changes Requested by Staff</p>
|
||||||
|
<p className="mt-1 text-amber-700">{booking.latestChangeRequestNote}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{pricingQuery.isError && (
|
{pricingQuery.isError && (
|
||||||
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Pricing failed</p>
|
<p className="font-semibold">Pricing failed</p>
|
||||||
<p className="mt-1 text-red-600">
|
<p className="mt-1 text-destructive/80">
|
||||||
{pricingQuery.error instanceof Error
|
{pricingQuery.error instanceof Error
|
||||||
? pricingQuery.error.message
|
? pricingQuery.error.message
|
||||||
: "An unexpected error occurred."}
|
: "An unexpected error occurred."}
|
||||||
@@ -315,11 +446,11 @@ function DraftBookingView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{uploadMutation.isError && (
|
{uploadMutation.isError && (
|
||||||
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Document upload failed</p>
|
<p className="font-semibold">Document upload failed</p>
|
||||||
<p className="mt-1 text-red-600">
|
<p className="mt-1 text-destructive/80">
|
||||||
{uploadMutation.error instanceof Error
|
{uploadMutation.error instanceof Error
|
||||||
? uploadMutation.error.message
|
? uploadMutation.error.message
|
||||||
: "An unexpected error occurred."}
|
: "An unexpected error occurred."}
|
||||||
@@ -329,11 +460,11 @@ function DraftBookingView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{submitMutation.isError && (
|
{submitMutation.isError && (
|
||||||
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Submission failed</p>
|
<p className="font-semibold">Submission failed</p>
|
||||||
<p className="mt-1 text-red-600">
|
<p className="mt-1 text-destructive/80">
|
||||||
{submitMutation.error instanceof Error
|
{submitMutation.error instanceof Error
|
||||||
? submitMutation.error.message
|
? submitMutation.error.message
|
||||||
: "An unexpected error occurred."}
|
: "An unexpected error occurred."}
|
||||||
@@ -343,11 +474,11 @@ function DraftBookingView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{cancelMutation.isError && (
|
{cancelMutation.isError && (
|
||||||
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Cancel failed</p>
|
<p className="font-semibold">Cancel failed</p>
|
||||||
<p className="mt-1 text-red-600">
|
<p className="mt-1 text-destructive/80">
|
||||||
{cancelMutation.error instanceof Error
|
{cancelMutation.error instanceof Error
|
||||||
? cancelMutation.error.message
|
? cancelMutation.error.message
|
||||||
: "An unexpected error occurred."}
|
: "An unexpected error occurred."}
|
||||||
@@ -358,7 +489,7 @@ function DraftBookingView({
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
pricingQuery.isSuccess ? "border-emerald-200 bg-emerald-50/30" : "",
|
pricingQuery.isSuccess ? "border-primary/20 bg-primary/5" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -441,22 +572,12 @@ function DraftBookingView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs font-medium text-primary underline-offset-2 hover:underline"
|
|
||||||
onClick={() => pricingQuery.refetch()}
|
|
||||||
>
|
|
||||||
Re-calculate pricing
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card ref={documentsRef}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<FileText className="size-4 text-primary" />
|
<FileText className="size-4 text-primary" />
|
||||||
@@ -468,6 +589,12 @@ function DraftBookingView({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-6">
|
<CardContent className="flex flex-col gap-6">
|
||||||
|
{docError && (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||||
|
<p className="font-semibold">{docError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="rounded-lg border border-primary/10 bg-primary/[0.02] p-4">
|
<div className="rounded-lg border border-primary/10 bg-primary/[0.02] p-4">
|
||||||
<h3 className="mb-3 flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-foreground">
|
<h3 className="mb-3 flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-foreground">
|
||||||
<Building2 className="size-3.5 text-primary" />
|
<Building2 className="size-3.5 text-primary" />
|
||||||
@@ -479,7 +606,7 @@ function DraftBookingView({
|
|||||||
<InfoItem label="Contact Person" value={contactName} />
|
<InfoItem label="Contact Person" value={contactName} />
|
||||||
<InfoItem label="Contact Email" value={contactEmail} />
|
<InfoItem label="Contact Email" value={contactEmail} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-[10px] text-muted-foreground">
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
To update your company information, go to{" "}
|
To update your company information, go to{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -500,56 +627,73 @@ function DraftBookingView({
|
|||||||
Upload Booking Documents
|
Upload Booking Documents
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{REQUIRED_DOC_FIELDS.map((doc) => (
|
{REQUIRED_DOC_FIELDS.map((doc) => {
|
||||||
<div
|
const isUploaded = uploadedCodes.has(doc.key);
|
||||||
key={doc.key}
|
return (
|
||||||
className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between"
|
<div
|
||||||
>
|
key={doc.key}
|
||||||
<label className="text-xs font-medium text-foreground">
|
className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between"
|
||||||
{doc.label}
|
>
|
||||||
</label>
|
<label className="flex items-center gap-2 text-xs font-medium text-foreground">
|
||||||
<div className="flex items-center gap-2">
|
{isUploaded && (
|
||||||
<input
|
<CheckCircle2 className="size-3.5 text-primary" />
|
||||||
ref={(el) => {
|
|
||||||
fileInputRefs.current[doc.key] = el;
|
|
||||||
}}
|
|
||||||
type="file"
|
|
||||||
accept=".pdf,.jpg,.jpeg,.png"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
handleFileSelect(
|
|
||||||
doc.key,
|
|
||||||
e.target.files?.[0] ?? null,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors",
|
|
||||||
selectedFiles[doc.key]
|
|
||||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
|
||||||
: "border-border bg-background text-muted-foreground hover:border-foreground/20 hover:text-foreground",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => fileInputRefs.current[doc.key]?.click()}
|
{doc.label}
|
||||||
>
|
</label>
|
||||||
<Upload className="size-3" />
|
<div className="flex items-center gap-2">
|
||||||
{selectedFiles[doc.key]
|
{isUploaded ? (
|
||||||
? selectedFiles[doc.key]!.name
|
<span className="inline-flex items-center gap-1 rounded-lg border border-primary/20 bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary">
|
||||||
: "Choose file"}
|
<CheckCircle2 className="size-3" />
|
||||||
</button>
|
Uploaded
|
||||||
{selectedFiles[doc.key] && (
|
</span>
|
||||||
<button
|
) : (
|
||||||
type="button"
|
<>
|
||||||
className="text-muted-foreground hover:text-red-500"
|
<input
|
||||||
onClick={() => handleFileSelect(doc.key, null)}
|
ref={(el) => {
|
||||||
>
|
fileInputRefs.current[doc.key] = el;
|
||||||
<XCircle className="size-4" />
|
}}
|
||||||
</button>
|
type="file"
|
||||||
)}
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
handleFileSelect(
|
||||||
|
doc.key,
|
||||||
|
e.target.files?.[0] ?? null,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||||
|
selectedFiles[doc.key]
|
||||||
|
? "border-primary/20 bg-primary/10 text-primary"
|
||||||
|
: "border-border bg-background text-muted-foreground hover:border-foreground/20 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
fileInputRefs.current[doc.key]?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Upload className="size-3" />
|
||||||
|
{selectedFiles[doc.key]
|
||||||
|
? selectedFiles[doc.key]!.name
|
||||||
|
: "Choose file"}
|
||||||
|
</button>
|
||||||
|
{selectedFiles[doc.key] && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => handleFileSelect(doc.key, null)}
|
||||||
|
>
|
||||||
|
<XCircle className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@@ -570,7 +714,7 @@ function DraftBookingView({
|
|||||||
: "Select files to upload"}
|
: "Select files to upload"}
|
||||||
</Button>
|
</Button>
|
||||||
{uploadMutation.isSuccess && (
|
{uploadMutation.isSuccess && (
|
||||||
<p className="mt-2 flex items-center gap-1.5 text-xs text-emerald-600">
|
<p className="mt-2 flex items-center gap-1.5 text-xs text-primary">
|
||||||
<CheckCircle2 className="size-3" />
|
<CheckCircle2 className="size-3" />
|
||||||
Documents uploaded successfully
|
Documents uploaded successfully
|
||||||
</p>
|
</p>
|
||||||
@@ -597,7 +741,11 @@ function DraftBookingView({
|
|||||||
</p>
|
</p>
|
||||||
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button type="button" variant="destructive" className="self-start">
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
<XCircle className="mr-1 h-4 w-4" />
|
<XCircle className="mr-1 h-4 w-4" />
|
||||||
Cancel Booking
|
Cancel Booking
|
||||||
</Button>
|
</Button>
|
||||||
@@ -647,41 +795,6 @@ function DraftBookingView({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="sticky bottom-0 z-20 -mx-4 border-t border-border bg-background px-4 py-4">
|
|
||||||
<div className="mx-auto flex max-w-5xl items-center justify-end gap-3">
|
|
||||||
{!canConfirm && (
|
|
||||||
<p className="mr-auto text-xs text-muted-foreground">
|
|
||||||
{pricingQuery.isLoading
|
|
||||||
? "Calculating price…"
|
|
||||||
: pricingQuery.isError
|
|
||||||
? "Price calculation failed."
|
|
||||||
: "Upload documents before confirming."}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate("/bookings")}
|
|
||||||
>
|
|
||||||
Back to Bookings
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => submitMutation.mutate()}
|
|
||||||
disabled={!canConfirm}
|
|
||||||
>
|
|
||||||
{submitMutation.isPending ? (
|
|
||||||
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle2 className="mr-1 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{submitMutation.isPending
|
|
||||||
? "Submitting..."
|
|
||||||
: "Confirm Booking Request"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -720,7 +833,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="size-3" />
|
<Calendar className="size-3" />
|
||||||
{booking.scheduledDate ?? booking.createdAt}
|
{format(
|
||||||
|
new Date(booking.scheduledDate ?? booking.createdAt),
|
||||||
|
"MMM d, yyyy HH:mm",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -728,8 +844,9 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{(booking.status === "CONFIRMED" ||
|
{(booking.status === "CONTRACT_READY" ||
|
||||||
booking.status === "IN_TRANSIT") && (
|
booking.status === "SIGNED_CUSTOMER" ||
|
||||||
|
booking.status === "FULLY_EXECUTED") && (
|
||||||
<Card className="border-primary/30 bg-primary/5">
|
<Card className="border-primary/30 bg-primary/5">
|
||||||
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
|
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -801,7 +918,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[9px] font-bold uppercase tracking-widest",
|
"text-xs font-bold uppercase tracking-widest",
|
||||||
isActive ? "text-primary" : "text-muted-foreground",
|
isActive ? "text-primary" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -815,7 +932,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<div className="flex flex-col gap-4 rounded-xl border border-border/50 bg-muted/20 p-5 md:flex-row md:items-center">
|
<div className="flex flex-col gap-4 rounded-xl border border-border/50 bg-muted/20 p-5 md:flex-row md:items-center">
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-background shadow-sm">
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-background shadow-sm">
|
||||||
{normalizedStatus === "CANCELLED" ? (
|
{normalizedStatus === "CANCELLED" ? (
|
||||||
<AlertTriangle className="size-5 text-red-500" />
|
<AlertTriangle className="size-5 text-destructive" />
|
||||||
) : (
|
) : (
|
||||||
<Info className="size-5 text-primary" />
|
<Info className="size-5 text-primary" />
|
||||||
)}
|
)}
|
||||||
@@ -837,7 +954,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
normalizedStatus !== "DELIVERED" && (
|
normalizedStatus !== "DELIVERED" && (
|
||||||
<div className="ml-auto flex items-center gap-4 border-l border-border pl-6">
|
<div className="ml-auto flex items-center gap-4 border-l border-border pl-6">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p className="text-[9px] font-bold uppercase text-muted-foreground">
|
<p className="text-xs font-bold uppercase text-muted-foreground">
|
||||||
Est. Waiting
|
Est. Waiting
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs font-black text-foreground">
|
<p className="text-xs font-black text-foreground">
|
||||||
@@ -864,7 +981,11 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<div className="flex flex-col items-center justify-between gap-4 rounded-xl border bg-muted/30 p-4 md:flex-row">
|
<div className="flex flex-col items-center justify-between gap-4 rounded-xl border bg-muted/30 p-4 md:flex-row">
|
||||||
<RouteEndpoint
|
<RouteEndpoint
|
||||||
label="Origin Yard"
|
label="Origin Yard"
|
||||||
station={booking.originStation}
|
station={
|
||||||
|
booking.originYard?.label ??
|
||||||
|
booking.originYard?.code ??
|
||||||
|
"—"
|
||||||
|
}
|
||||||
icon={<MapPin />}
|
icon={<MapPin />}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-1 text-primary">
|
<div className="flex flex-col items-center gap-1 text-primary">
|
||||||
@@ -874,14 +995,18 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-primary/20 bg-primary/5 text-[9px] font-bold uppercase"
|
className="border-primary/20 bg-primary/5 text-xs font-bold uppercase"
|
||||||
>
|
>
|
||||||
Rail
|
Rail
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<RouteEndpoint
|
<RouteEndpoint
|
||||||
label="Destination Yard"
|
label="Destination Yard"
|
||||||
station={booking.destinationStation}
|
station={
|
||||||
|
booking.destinationYard?.label ??
|
||||||
|
booking.destinationYard?.code ??
|
||||||
|
"—"
|
||||||
|
}
|
||||||
icon={<MapPin />}
|
icon={<MapPin />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1038,10 +1163,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<InfoItem label="Customer ID" value={booking.customerId} />
|
<InfoItem label="Customer ID" value={booking.customerId} />
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge variant="outline" className="bg-background text-[9px]">
|
<Badge variant="outline" className="bg-background text-xs">
|
||||||
Hazardous: {booking.isHazardous ? "Yes" : "No"}
|
Hazardous: {booking.isHazardous ? "Yes" : "No"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className="bg-background text-[9px]">
|
<Badge variant="outline" className="bg-background text-xs">
|
||||||
Refrigerated: {booking.isRefrigerated ? "Yes" : "No"}
|
Refrigerated: {booking.isRefrigerated ? "Yes" : "No"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -1055,7 +1180,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
{booking.freightSubtype && (
|
{booking.freightSubtype && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-tight text-muted-foreground">
|
||||||
Cargo Description
|
Cargo Description
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-foreground leading-relaxed italic">
|
<p className="text-xs text-foreground leading-relaxed italic">
|
||||||
@@ -1067,7 +1192,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-tight text-muted-foreground">
|
||||||
Financial Terms
|
Financial Terms
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-lg bg-amber-50/50 border border-amber-100 p-2">
|
<div className="rounded-lg bg-amber-50/50 border border-amber-100 p-2">
|
||||||
@@ -1108,7 +1233,7 @@ function RouteEndpoint({
|
|||||||
{icon && <div className="[&_svg]:size-5">{icon}</div>}
|
{icon && <div className="[&_svg]:size-5">{icon}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p className="text-[9px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-black text-foreground">{station}</p>
|
<p className="text-sm font-black text-foreground">{station}</p>
|
||||||
@@ -1134,7 +1259,7 @@ function InfoItem({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-tight text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs font-bold text-foreground">{value ?? "—"}</p>
|
<p className="text-xs font-bold text-foreground">{value ?? "—"}</p>
|
||||||
@@ -1145,18 +1270,33 @@ function InfoItem({
|
|||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
DRAFT: "bg-slate-50 text-slate-700 border-slate-200",
|
DRAFT: "bg-muted text-muted-foreground border-border",
|
||||||
CONFIRMED: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
CHANGES_REQUESTED: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
IN_TRANSIT: "bg-sky-50 text-sky-700 border-sky-200",
|
SUBMITTED: "bg-primary/10 text-primary border-primary/20",
|
||||||
DELIVERED: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
PENDING_APPROVAL: "bg-primary/10 text-primary border-primary/20",
|
||||||
CANCELLED: "bg-red-50 text-red-700 border-red-200",
|
APPROVED_PENDING_SIGNATURE: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
APPROVED: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
CONTRACT_READY: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
SIGNED_CUSTOMER: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
FULLY_EXECUTED: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
PNR_GENERATED: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
PAYMENT_VERIFICATION_IN_PROGRESS: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
PAID: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
CONFIRMED: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
IN_TRANSIT: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
PENDING_CONSOLIDATION: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
CONSOLIDATED: "bg-primary/10 text-primary border-primary/20",
|
||||||
|
COMPLETED: "bg-muted text-foreground border-border",
|
||||||
|
DELIVERED: "bg-muted text-foreground border-border",
|
||||||
|
REJECTED: "bg-destructive/10 text-destructive border-destructive/20",
|
||||||
|
CANCELLED: "bg-destructive/10 text-destructive border-destructive/20",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]",
|
"px-2 py-0.5 font-bold uppercase tracking-wider text-xs",
|
||||||
statusColors[status] || "bg-muted",
|
statusColors[status] || "bg-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export default function MyBookings() {
|
|||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
return (
|
return (
|
||||||
b.reference.toLowerCase().includes(term) ||
|
b.reference.toLowerCase().includes(term) ||
|
||||||
b.originStation.toLowerCase().includes(term) ||
|
(b.originYard?.label ?? b.originYard?.code ?? "").toLowerCase().includes(term) ||
|
||||||
b.destinationStation.toLowerCase().includes(term) ||
|
(b.destinationYard?.label ?? b.destinationYard?.code ?? "").toLowerCase().includes(term) ||
|
||||||
b.status.toLowerCase().includes(term)
|
b.status.toLowerCase().includes(term)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -85,8 +85,8 @@ export default function MyBookings() {
|
|||||||
<Package className="h-5 w-5" />
|
<Package className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-slate-900">{booking.reference}</p>
|
<p className="font-medium text-foreground">{booking.reference}</p>
|
||||||
<p className="text-sm text-slate-500">{booking.scheduledDate ?? booking.createdAt}</p>
|
<p className="text-sm text-muted-foreground">{booking.scheduledDate ?? booking.createdAt}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -96,10 +96,10 @@ export default function MyBookings() {
|
|||||||
id: "route",
|
id: "route",
|
||||||
header: "Route",
|
header: "Route",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span>{row.original.originStation}</span>
|
<span>{row.original.originYard?.label ?? row.original.originYard?.code ?? "—"}</span>
|
||||||
<ArrowRight className="text-slate-400" />
|
<ArrowRight className="text-muted-foreground" />
|
||||||
<span>{row.original.destinationStation}</span>
|
<span>{row.original.destinationYard?.label ?? row.original.destinationYard?.code ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -111,9 +111,9 @@ export default function MyBookings() {
|
|||||||
const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0;
|
const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0;
|
||||||
const containerType = b.containers?.[0]?.type ?? null;
|
const containerType = b.containers?.[0]?.type ?? null;
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-slate-700">
|
<div className="text-sm text-muted-foreground">
|
||||||
<p>{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}</p>
|
<p>{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}</p>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-muted-foreground">
|
||||||
{containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}{b.cargoTotalWeightVgm}t
|
{containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}{b.cargoTotalWeightVgm}t
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +124,7 @@ export default function MyBookings() {
|
|||||||
id: "transportMode",
|
id: "transportMode",
|
||||||
header: "Transport",
|
header: "Transport",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-slate-700">
|
<span className="text-sm text-muted-foreground">
|
||||||
{row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"}
|
{row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -172,10 +172,10 @@ export default function MyBookings() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="p-6 flex-row justify-between">
|
<Card className="p-6 flex-row justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||||
My Bookings
|
My Bookings
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-secondary-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
View and manage your freight booking requests.
|
View and manage your freight booking requests.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,8 +205,8 @@ export default function MyBookings() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-between">
|
<CardContent className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">Total Bookings</p>
|
<p className="text-sm text-muted-foreground">Total Bookings</p>
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
<h3 className="mt-2 text-3xl font-bold text-foreground">
|
||||||
{bookings.length}
|
{bookings.length}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,8 +219,8 @@ export default function MyBookings() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-between">
|
<CardContent className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">Active Bookings</p>
|
<p className="text-sm text-muted-foreground">Active Bookings</p>
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
<h3 className="mt-2 text-3xl font-bold text-foreground">
|
||||||
{activeCount}
|
{activeCount}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,8 +233,8 @@ export default function MyBookings() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-between">
|
<CardContent className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">Pending Approval</p>
|
<p className="text-sm text-muted-foreground">Pending Approval</p>
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
<h3 className="mt-2 text-3xl font-bold text-foreground">
|
||||||
{pendingCount}
|
{pendingCount}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,9 +263,9 @@ export default function MyBookings() {
|
|||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
{total === 0 && dataTableStatus === "success" ? (
|
{total === 0 && dataTableStatus === "success" ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||||
<Package className="h-12 w-12 text-slate-300 mb-4" />
|
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<h3 className="text-sm font-semibold text-slate-900">No bookings found</h3>
|
<h3 className="text-sm font-semibold text-foreground">No bookings found</h3>
|
||||||
<p className="text-xs text-slate-500 mt-1 max-w-sm">
|
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
|
||||||
{searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."}
|
{searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,15 +299,15 @@ export default function MyBookings() {
|
|||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const styles: Record<string, string> = {
|
const styles: Record<string, string> = {
|
||||||
DRAFT: "bg-amber-100 text-amber-700",
|
DRAFT: "bg-amber-100 text-amber-700",
|
||||||
CONFIRMED: "bg-sky-100 text-sky-700",
|
CONFIRMED: "bg-primary/10 text-primary",
|
||||||
IN_TRANSIT: "bg-indigo-100 text-indigo-700",
|
IN_TRANSIT: "bg-muted text-foreground",
|
||||||
DELIVERED: "bg-emerald-100 text-emerald-700",
|
DELIVERED: "bg-primary/10 text-primary",
|
||||||
CANCELLED: "bg-red-100 text-red-700",
|
CANCELLED: "bg-destructive/10 text-destructive",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status] ?? "bg-slate-100 text-slate-700"}`}
|
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status] ?? "bg-muted text-muted-foreground"}`}
|
||||||
>
|
>
|
||||||
{status.replace(/_/g, ' ')}
|
{status.replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
} from "@/types/fileUploadSettings";
|
} from "@/types/fileUploadSettings";
|
||||||
import {
|
import {
|
||||||
bookingsService,
|
bookingsService,
|
||||||
|
BookingListFilter,
|
||||||
CreateBookingPayload,
|
CreateBookingPayload,
|
||||||
GeneratePriceResponse,
|
GeneratePriceResponse,
|
||||||
} from "./bookings.service";
|
} from "./bookings.service";
|
||||||
@@ -115,7 +116,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
bookings: {
|
bookings: {
|
||||||
list: endpoint<void, PaginatedResponse<Freight.IBooking>>(
|
list: endpoint<BookingListFilter | void, PaginatedResponse<Freight.IBooking>>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"list",
|
"list",
|
||||||
bookingsService.list,
|
bookingsService.list,
|
||||||
|
|||||||
@@ -47,9 +47,19 @@ export interface SignContractPayload {
|
|||||||
consentText?: string;
|
consentText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookingListFilter {
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "ASC" | "DESC";
|
||||||
|
}
|
||||||
|
|
||||||
export const bookingsService = {
|
export const bookingsService = {
|
||||||
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
|
list: async (
|
||||||
const { data } = await client.get("/api/bookings");
|
filter: BookingListFilter | void = {},
|
||||||
|
): Promise<PaginatedResponse<Freight.IBooking>> => {
|
||||||
|
const { data } = await client.get("/api/bookings", { params: filter });
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
get: async (id: string): Promise<Freight.IBooking> => {
|
get: async (id: string): Promise<Freight.IBooking> => {
|
||||||
|
|||||||
@@ -34,10 +34,24 @@ export enum FreightType {
|
|||||||
|
|
||||||
export enum BookingStatus {
|
export enum BookingStatus {
|
||||||
Draft = "DRAFT",
|
Draft = "DRAFT",
|
||||||
Confirmed = "CONFIRMED",
|
Submitted = "SUBMITTED",
|
||||||
|
ChangesRequested = "CHANGES_REQUESTED",
|
||||||
|
PendingApproval = "PENDING_APPROVAL",
|
||||||
|
ApprovedPendingSignature = "APPROVED_PENDING_SIGNATURE",
|
||||||
|
Approved = "APPROVED",
|
||||||
|
ContractReady = "CONTRACT_READY",
|
||||||
|
SignedCustomer = "SIGNED_CUSTOMER",
|
||||||
|
FullyExecuted = "FULLY_EXECUTED",
|
||||||
|
PnrGenerated = "PNR_GENERATED",
|
||||||
|
PaymentVerificationInProgress = "PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
|
Paid = "PAID",
|
||||||
InTransit = "IN_TRANSIT",
|
InTransit = "IN_TRANSIT",
|
||||||
|
Completed = "COMPLETED",
|
||||||
Delivered = "DELIVERED",
|
Delivered = "DELIVERED",
|
||||||
|
Rejected = "REJECTED",
|
||||||
Cancelled = "CANCELLED",
|
Cancelled = "CANCELLED",
|
||||||
|
PendingConsolidation = "PENDING_CONSOLIDATION",
|
||||||
|
Consolidated = "CONSOLIDATED",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ConsignmentStatus {
|
export enum ConsignmentStatus {
|
||||||
@@ -158,6 +172,14 @@ export interface IConsignment extends BaseEntity {
|
|||||||
destinationStation: string;
|
destinationStation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IYard extends BaseEntity {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
country: string;
|
||||||
|
isActive: boolean;
|
||||||
|
displayOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IBooking extends BaseEntity {
|
export interface IBooking extends BaseEntity {
|
||||||
reference: string;
|
reference: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
@@ -177,8 +199,8 @@ export interface IBooking extends BaseEntity {
|
|||||||
lastMileDeliveryAddress?: string | null;
|
lastMileDeliveryAddress?: string | null;
|
||||||
|
|
||||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
|
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
|
||||||
originStation: string;
|
originYard?: IYard | null;
|
||||||
destinationStation: string;
|
destinationYard?: IYard | null;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
|
|
||||||
freightType: FreightType;
|
freightType: FreightType;
|
||||||
@@ -208,7 +230,20 @@ export interface IBooking extends BaseEntity {
|
|||||||
signedByCeoId?: string | null;
|
signedByCeoId?: string | null;
|
||||||
signedByCeoAt?: string | null;
|
signedByCeoAt?: string | null;
|
||||||
|
|
||||||
files?: Array<{ id: string; name: string; url: string; mimeType: string }>;
|
files?: Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
resourceId: string;
|
||||||
|
resource: string;
|
||||||
|
signedUrl?: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
latestChangeRequestNote?: string | null;
|
||||||
|
contractSummary?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IInvoice extends BaseEntity {
|
export interface IInvoice extends BaseEntity {
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -305,6 +305,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
date-fns:
|
||||||
|
specifier: ^3.6.0
|
||||||
|
version: 3.6.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.14.0
|
specifier: ^1.14.0
|
||||||
version: 1.16.0(react@19.2.6)
|
version: 1.16.0(react@19.2.6)
|
||||||
|
|||||||
Reference in New Issue
Block a user