resolve confilict

This commit is contained in:
marshal
2026-06-05 14:07:26 +03:00
40 changed files with 3823 additions and 768 deletions

View File

@@ -38,6 +38,7 @@ import {
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
@@ -92,6 +93,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
BackofficeModule,
DemoPermissionsModule,
FreightAuthModule,
PaymentModule,
//New Modules
TrainsModule,
WagonsModule,

View File

@@ -4,6 +4,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
const SUPER_ADMIN_ROLE = 'super_admin';
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
type PermissionLike = { key?: string };
type MeLikeUser = {
@@ -25,6 +26,15 @@ export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE);
}
export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean {
if (!user?.roles?.length) return false;
return user.roles.some((r) => r.key === ORGANIZATION_ADMIN_ROLE);
}
export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boolean {
return isSuperAdmin(user) || isOrganizationAdmin(user);
}
/** Flat permission keys from JWT / session user (roles + position permissions). */
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
if (!user) return [];
@@ -90,7 +100,7 @@ export function assertCanApproveBookingStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,
): void {
if (isSuperAdmin(user)) return;
if (isFreightApprovalAdmin(user)) return;
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
if (!perm) {
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm';
/**
* shipping_lines was created without a unique index on code; seeder upserts require it.
*/
export class AddShippingLinesCodeUniqueIndex1749800000000 implements MigrationInterface {
name = 'AddShippingLinesCodeUniqueIndex1749800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND tablename = 'shipping_lines' AND indexdef ILIKE '%UNIQUE%code%' LIMIT 1`,
);
if (existing.length === 0) {
await queryRunner.createIndex(
'freight.shipping_lines',
new TableIndex({
name: 'UQ_shipping_lines_code',
columnNames: ['code'],
isUnique: true,
}),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropIndex('freight.shipping_lines', 'UQ_shipping_lines_code');
}
}

View File

@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* file_upload_settings / file_upload_fields entities had no migration; seeder requires both tables.
*/
export class CreateFileUploadSettingsTables1749900000000 implements MigrationInterface {
name = 'CreateFileUploadSettingsTables1749900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.file_upload_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(128) NOT NULL,
label VARCHAR(256) NOT NULL,
description TEXT,
entity VARCHAR(32) NOT NULL DEFAULT 'other',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_settings_code"
ON freight.file_upload_settings (code);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.file_upload_fields (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
setting_id UUID NOT NULL,
file_key VARCHAR(128) NOT NULL,
file_label VARCHAR(256) NOT NULL,
help_text TEXT,
is_required BOOLEAN NOT NULL DEFAULT false,
is_multiple BOOLEAN NOT NULL DEFAULT false,
max_files INTEGER NOT NULL DEFAULT 1,
allowed_extensions TEXT[] NOT NULL DEFAULT '{}'::text[],
max_size_mb INTEGER NOT NULL DEFAULT 10,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CONSTRAINT "CHK_file_upload_fields_max_files" CHECK (max_files > 0),
CONSTRAINT "CHK_file_upload_fields_max_size_mb" CHECK (max_size_mb > 0),
CONSTRAINT "FK_file_upload_fields_setting"
FOREIGN KEY (setting_id)
REFERENCES freight.file_upload_settings(id)
ON DELETE CASCADE
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_fields_setting_file_key"
ON freight.file_upload_fields (setting_id, file_key);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_fields CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_settings CASCADE`);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Train entity gained extended fields; baseline trains table only had code/capacity/status/notes.
*/
export class AddTrainExtendedColumns1750000000000 implements MigrationInterface {
name = 'AddTrainExtendedColumns1750000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.trains
ADD COLUMN IF NOT EXISTS train_number VARCHAR(20),
ADD COLUMN IF NOT EXISTS train_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS route_id UUID,
ADD COLUMN IF NOT EXISTS origin_station_id UUID,
ADD COLUMN IF NOT EXISTS destination_station_id UUID,
ADD COLUMN IF NOT EXISTS departure_time TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS arrival_time TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS locomotive_number VARCHAR(50),
ADD COLUMN IF NOT EXISTS remarks TEXT;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_train_number"
ON freight.trains (train_number)
WHERE train_number IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_train_number"`);
await queryRunner.query(`
ALTER TABLE freight.trains
DROP COLUMN IF EXISTS remarks,
DROP COLUMN IF EXISTS locomotive_number,
DROP COLUMN IF EXISTS arrival_time,
DROP COLUMN IF EXISTS departure_time,
DROP COLUMN IF EXISTS destination_station_id,
DROP COLUMN IF EXISTS origin_station_id,
DROP COLUMN IF EXISTS route_id,
DROP COLUMN IF EXISTS train_name,
DROP COLUMN IF EXISTS train_number;
`);
}
}

View File

@@ -0,0 +1,96 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreatePaymentTable1780639311366 implements MigrationInterface {
name = "CreatePaymentTable1780639311366";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE freight.payments_type_enum AS ENUM ('booking');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_currency_enum AS ENUM ('ETB', 'USD');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_status_enum AS ENUM (
'action-required',
'processing',
'success',
'failed',
'canceled',
'refunded'
);
`);
await queryRunner.query(`
CREATE TABLE freight.payments (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
ref_id varchar(255) NOT NULL,
type freight.payments_type_enum NOT NULL,
method freight.payments_method_enum NOT NULL,
currency freight.payments_currency_enum NOT NULL,
amount numeric NOT NULL,
raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb,
client_action json,
merchant_order_id varchar(255) NOT NULL,
transaction_id varchar(255),
status freight.payments_status_enum NOT NULL DEFAULT 'action-required',
paid_at date,
refunded_at date,
expires_at date,
failer_code varchar(30),
failer_message varchar(255),
created_at TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT PK_payments PRIMARY KEY (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> {
await queryRunner.query(`
DROP TABLE IF EXISTS freight.payments;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_status_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_currency_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_method_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_type_enum;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AlterClientActionToJsonb1780639978834 implements MigrationInterface {
name = "AlterClientActionToJsonb1780639978834";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action TYPE jsonb
USING client_action::jsonb;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action DROP DEFAULT;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action TYPE json
USING client_action::json;
`);
}
}

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdatePaymentTimestamp1780644945086 implements MigrationInterface {
name = "UpdatePaymentTimestamp1780644945086";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN refunded_at TYPE timestamp
USING refunded_at::timestamp;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN expires_at TYPE timestamp
USING expires_at::timestamp;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN refunded_at TYPE timestamptz
USING refunded_at::timestamptz;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN expires_at TYPE timestamptz
USING expires_at::timestamptz;
`);
}
}

View File

@@ -0,0 +1,23 @@
import { IsEnum, IsOptional, IsString } from "class-validator";
export enum PaymentStatus {
REQUIRES_ACTION,
PROCESSING,
SUCCEEDED,
FAILED,
CANCELLED,
REFUNDED,
}
export class UpdatePaymentStatusDto {
@IsString()
orderId!: string;
@IsEnum(PaymentStatus)
status!: PaymentStatus
@IsOptional()
@IsString()
failureMessage?: string
}

View File

@@ -0,0 +1,62 @@
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
type PaymentType = "booking"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
type Currency = "ETB" | "USD"
type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@Entity({ schema: 'freight', name: 'payments' })
export class PaymentEntity extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id!: string
@Column({ type: 'varchar', length: 255, name: "ref_id" })
refId!: string
@Column({ type: "enum", enum: ["booking"] })
type!: PaymentType;
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] })
method!: PaymentMethod
@Column({ type: "enum", enum: ["ETB", "USD"] })
currency!: Currency
@Column({ type: "numeric" })
amount!: number
@Column({ type: "jsonb", default: {}, name: "raw_initiation" })
rawInitiation?: Record<string, unknown>
@Column({ type: "jsonb", name: "client_action" })
clientAction?: Record<string, unknown>;
@Column({ type: "varchar", length: 255, unique: true, name: "merchant_order_id", })
merchantOrderId!: string
@Column({ type: "varchar", length: 255, unique: true, name: "transaction_id", })
transactionId?: string
@Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" })
status!: PaymentStatus
@Column({ type: "date", nullable: true, name: "paid_at" })
paidAt?: Date
@Column({ type: "timestamp", nullable: true, name: "refunded_at" })
refundedAt?: Date
@Column({ type: "timestamp", nullable: true, name: "expires_at" })
expiresAt?: Date
@Column({ type: "varchar", length: 30, nullable: true, name: "failer_code" })
failerCode?: string
@Column({ type: "varchar", length: 255, nullable: true, name: "failer_message" })
failureMessage?: string
@CreateDateColumn({ name: "created_at" })
createdAt!: Date
}

View File

@@ -0,0 +1,53 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import { Public } from "@edr/api-common";
import { randomUUID } from "crypto";
import { Response } from "express"
@Public()
@Controller("payments")
export class PaymentController {
constructor(private readonly paymentService: PaymentService) { }
@Post("/initiate")
async initiatePayment() {
//Only for testing..
const data = await this.paymentService.pay(20, "ETB", "telebirr", (_) => {
return new Promise((resp, _) => {
resp({
id: randomUUID(),
type: "booking"
})
});
})
return data
}
@Get("/telebirr/:refId")
async pay(@Param("refId", ParseUUIDPipe) refId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByRefIdAndMethod(refId, "telebirr")
if (!payment) {
throw new NotFoundException('payment not found')
}
return res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Redirecting...</title>
</head>
<body>
<p>Redirecting...</p>
<script>
window.location.href = "${payment.clientAction?.url}";
</script>
</body>
</html>
`);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentService } from "./payment.service";
import { HttpModule } from "@nestjs/axios";
import { PaymentController } from "./payment.controller";
import { ConfigModule } from "@nestjs/config";
import { PaymentRepository } from "./payment.repository";
import { WebhookController } from "./webhooks/webhook.controller";
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
@Module({
imports: [HttpModule, ConfigModule],
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
controllers: [PaymentController, WebhookController]
})
export class PaymentModule { }

View File

@@ -0,0 +1,39 @@
import { Injectable } from "@nestjs/common";
import { DataSource, FindOptionsWhere, QueryDeepPartialEntity, QueryRunner, Repository } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
@Injectable()
export class PaymentRepository {
private readonly paymentRepo: Repository<PaymentEntity>;
constructor(private readonly dataSource: DataSource) {
this.paymentRepo = this.dataSource.getRepository(PaymentEntity)
}
async createTr(qr: QueryRunner, data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt">): Promise<PaymentEntity> {
const payment = qr.manager.create(PaymentEntity, data)
return qr.manager.save(payment)
}
findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> {
return this.paymentRepo.findOneBy(options);
}
update(where: FindOptionsWhere<PaymentEntity>, data: QueryDeepPartialEntity<PaymentEntity>) {
return this.paymentRepo.update(where, data)
}
getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]) {
return this.paymentRepo
.createQueryBuilder('payment')
.where('payment.method = :method', { method })
.andWhere('payment.refId = :refId', { refId })
.andWhere('payment.status IN (:...statuses)', {
statuses: ['action-required'],
})
.andWhere('payment.expiresAt > :now', { now: new Date() })
.getOne();
}
}

View File

@@ -0,0 +1,113 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DataSource, QueryRunner } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentStrategy } from "./strategies/payment.strategy";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentRepository } from "./payment.repository";
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
import * as crypto from 'crypto';
import { PaymentStatus, UpdatePaymentStatusDto } from "./dto/update-payment-status.dto";
type PaymentMethod = PaymentEntity["method"]
type CurrencyType = PaymentEntity["currency"]
@Injectable()
export class PaymentService {
private strategies: Map<PaymentMethod, PaymentStrategy>;
constructor(
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) {
this.strategies = new Map([
["telebirr", this.telebirrPaymentStategy as PaymentStrategy]
])
}
async pay(amount: number, currency: CurrencyType, method: PaymentMethod, cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{
refId: string,
clientAction: ClientAction,
status: PaymentEntity["status"],
paidAt?: string,
failureCode?: string,
failureMessage?: string,
}> {
const strategy = this.strategies.get(method)
if (!strategy) {
throw new NotFoundException("strategy not found")
}
const orderId = `freigh${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic
const paymentResp = await strategy.pay({
amountMinor: amount,
currency: currency,
merchantOrderId: orderId,
platform: payform,
});
const queryRunner = this.datasource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
console.log(paymentResp.expiresAt)
try {
const resp = await cb(queryRunner)
const payment = await this.paymentRepo.createTr(queryRunner, {
amount,
currency,
method,
refId: resp.id,
type: resp.type,
merchantOrderId: orderId,
rawInitiation: paymentResp.rawInitiation,
clientAction: paymentResp.clientAction,
expiresAt: paymentResp.expiresAt
})
await queryRunner.commitTransaction()
return {
refId: payment.refId,
clientAction: paymentResp.clientAction,
status: payment.status,
paidAt: payment.paidAt?.toISOString(),
failureCode: payment.failerCode ?? undefined,
failureMessage: payment.failureMessage ?? undefined,
}
} catch (err) {
await queryRunner.rollbackTransaction()
throw new Error("payment failed")
} finally {
await queryRunner.release()
}
}
async getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method)
}
async handleTelebirrPaymentCb(dto: UpdatePaymentStatusDto): Promise<void> {
switch (dto.status) {
case PaymentStatus.SUCCEEDED:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "success" })
break;
case PaymentStatus.FAILED:
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;
}
}
}

View File

@@ -0,0 +1,8 @@
import { Injectable } from "@nestjs/common";
import { ProviderInitiationInput, ProviderInitiationResult } from "./payments.types";
@Injectable()
export abstract class PaymentStrategy {
abstract pay(data: ProviderInitiationInput): Promise<ProviderInitiationResult>
}

View File

@@ -0,0 +1,303 @@
import { Injectable, Logger } from "@nestjs/common";
import { PaymentStrategy } from "./payment.strategy";
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as https from 'node:https';
import { PaymentEntity } from "../entities/payment.entity";
import { ProviderInitiationInput, ProviderInitiationResult, ProviderStatus } from "./payments.types";
import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse } from "./telebirr/telebirr.types";
import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject } from "./telebirr/telebirr.crypto";
// type PaymentCurrency = PaymentEntity["currency"]
type PaymentIntentStatus = PaymentEntity["status"]
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
@Injectable()
export class PaymentTelebirrStrategy implements PaymentStrategy {
async pay(data: ProviderInitiationInput): Promise<any> {
// const refId = randomUUID()
// const orderId = createMerchantOrderId()
const resp = await this.initiate(data)
return resp;
}
// readonly method = PaymentMethodType.TELEBIRR;
private readonly logger = new Logger(PaymentTelebirrStrategy.name);
private readonly httpsAgent: https.Agent;
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const insecure = this.config.get<boolean>('telebirr.insecureTls');
if (insecure) {
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
}
this.httpsAgent = new https.Agent({
rejectUnauthorized: !insecure,
secureProtocol: 'TLSv1_2_method',
});
}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildCreateOrderRequest(input);
const response = await this.requestCreateOrder(fabricToken, requestBody);
const prepayId = response.biz_content?.prepay_id;
if (!prepayId) {
throw new Error(
`Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`,
);
}
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
const platform = input.platform ?? 'web';
const clientAction =
platform === 'mobile'
? {
type: 'LAUNCH_APP' as const,
prepayId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
return {
providerOrderId: prepayId,
clientAction,
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
const response = await this.postJson<QueryOrderResponse>(
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
requestBody,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
const tradeStatus = response.biz_content?.trade_status;
const providerTxnId =
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
const mapped = this.mapTradeStatus(tradeStatus);
return {
status: mapped,
providerTxnId,
failureCode:
mapped === "failed" && tradeStatus ? tradeStatus : undefined,
rawResponse: response as Record<string, unknown>,
};
}
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'PAY_SUCCESS':
return "success";
case 'PAY_FAILED':
case 'ORDER_CLOSED':
return "failed";
case 'WAIT_PAY':
return "action-required";
case 'PAYING':
return "processing";
default:
return "processing";
}
}
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'Completed':
return "success";
case 'Failure':
case 'Expired':
return "failed";
case 'Paying':
case 'Pending':
return "processing";
default:
return "processing";
}
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
if (!this.publicKey) {
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
return false;
}
return verifyRequestObject(payload, this.publicKey);
}
private async applyFabricToken(): Promise<string> {
console.log(this.baseUrl, "base url")
const response = await this.postJson<FabricTokenResponse>(
`${this.baseUrl}/payment/v1/token`,
{ appSecret: this.appSecret },
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
},
);
if (!response?.token) {
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
}
return response.token;
}
private async requestCreateOrder(
fabricToken: string,
body: CreateOrderRequest,
): Promise<CreateOrderResponse> {
return this.postJson<CreateOrderResponse>(
`${this.baseUrl}/payment/v1/inapp/createOrder`,
body,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
}
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
const totalAmount = String(input.amountMinor / 100);
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.preorder' as const,
version: '1.0' as const,
biz_content: {
notify_url: this.notifyUrl,
appid: this.merchantAppId,
merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const,
title: `EDR Booking`,
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,
},
};
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.queryorder',
version: '1.0',
biz_content: {
appid: this.merchantAppId,
merch_code: this.merchantCode,
merch_order_id: merchantOrderId,
},
};
const sign = signRequestObject(req as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildCheckoutUrl(prepayId: string): string {
const map: Record<string, string> = {
appid: this.merchantAppId,
merch_code: this.merchantCode,
nonce_str: createNonceStr(),
prepay_id: prepayId,
timestamp: createTimestamp(),
};
const sign = signRequestObject(map, this.privateKey);
const rawRequest = [
`appid=${map.appid}`,
`merch_code=${map.merch_code}`,
`nonce_str=${map.nonce_str}`,
`prepay_id=${map.prepay_id}`,
`timestamp=${map.timestamp}`,
'sign_type=SHA256WithRSA',
`sign=${sign}`,
'version=1.0',
'trade_type=Checkout',
].join('&');
return `${this.webBaseUrl}${rawRequest}`;
}
private computeExpiresAt(timeoutExpress: string): Date {
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
return new Date(Date.now() + minutes * 60_000);
}
private toMinutes(n: number, unit: string): number {
switch (unit) {
case 's': return Math.max(1, Math.round(n / 60));
case 'm': return n;
case 'h': return n * 60;
case 'd': return n * 60 * 24;
default: return 15;
}
}
private async postJson<T>(
url: string,
body: unknown,
headers: Record<string, string>,
): Promise<T> {
const config: AxiosRequestConfig = {
headers,
timeout: TELEBIRR_HTTP_TIMEOUT_MS,
httpsAgent: this.httpsAgent,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
);
} else {
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: CreateOrderRequest): Record<string, unknown> {
const { sign: _sign, ...rest } = body;
return rest;
}
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
private get publicKey(): string {
return this.config.get<string>('telebirr.publicKey') ?? '';
}
}

View File

@@ -0,0 +1,39 @@
import { PaymentEntity } from "../entities/payment.entity";
type PaymentIntentStatus = PaymentEntity["status"]
type PaymentMethodType = PaymentEntity["method"]
export type PaymentPlatform = 'web' | 'mobile';
export type ClientAction =
| { type: 'REDIRECT'; url: string }
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
export interface ProviderInitiationInput {
merchantOrderId: string;
// bookingRef: string;
amountMinor: number;
currency: string;
platform?: PaymentPlatform;
}
export interface ProviderInitiationResult {
providerOrderId: string;
clientAction: ClientAction;
expiresAt: Date;
rawInitiation: Record<string, unknown>;
}
export interface ProviderStatus {
status: PaymentIntentStatus;
providerTxnId?: string;
failureCode?: string;
failureMessage?: string;
rawResponse: Record<string, unknown>;
}
export interface PaymentProvider {
readonly method: PaymentMethodType;
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
}

View File

@@ -0,0 +1,98 @@
import * as crypto from 'crypto';
const EXCLUDE_FIELDS = new Set([
'sign',
'sign_type',
'header',
'refund_info',
'openType',
'raw_request',
'biz_content',
]);
const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export function buildCanonicalString(requestObject: Record<string, unknown>): string {
const fieldMap: Record<string, unknown> = {};
for (const key of Object.keys(requestObject)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = requestObject[key];
}
const biz = requestObject['biz_content'];
if (biz && typeof biz === 'object') {
for (const key of Object.keys(biz as Record<string, unknown>)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = (biz as Record<string, unknown>)[key];
}
}
return Object.keys(fieldMap)
.sort()
.map((k) => `${k}=${fieldMap[k]}`)
.join('&');
}
export function signRequestObject(
requestObject: Record<string, unknown>,
privateKey: string,
): string {
return signString(buildCanonicalString(requestObject), privateKey);
}
export function verifyRequestObject(
requestObject: Record<string, unknown>,
publicKey: string,
): boolean {
const signature = requestObject['sign'];
if (typeof signature !== 'string' || signature.length === 0) return false;
return verifySignature(buildCanonicalString(requestObject), signature, publicKey);
}
export function signString(text: string, privateKey: string): string {
const signature = crypto.sign('sha256', Buffer.from(text), {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
});
return signature.toString('base64');
}
export function verifySignature(
text: string,
signatureBase64: string,
publicKey: string,
): boolean {
try {
return crypto.verify(
'sha256',
Buffer.from(text),
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
},
Buffer.from(signatureBase64, 'base64'),
);
} catch {
return false;
}
}
export function createTimestamp(): string {
return Math.round(Date.now() / 1000).toString();
}
export function createNonceStr(length = 32): string {
const bytes = crypto.randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) {
out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length];
}
return out;
}
export function createMerchantOrderId(): string {
return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`;
}

View File

@@ -0,0 +1,69 @@
export interface FabricTokenResponse {
token: string;
expires_in?: number | string;
}
export interface CreateOrderBizContent {
notify_url: string;
appid: string;
merch_code: string;
merch_order_id: string;
trade_type: 'Checkout' | 'InApp' | 'MiniApp';
title: string;
total_amount: string;
trans_currency: string;
timeout_express: string;
}
export interface CreateOrderRequest {
timestamp: string;
nonce_str: string;
method: 'payment.preorder';
version: '1.0';
biz_content: CreateOrderBizContent;
sign: string;
sign_type: 'SHA256WithRSA';
}
export interface CreateOrderResponse {
code?: string;
msg?: string;
biz_content?: {
prepay_id?: string;
receiveCode?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export type TelebirrTradeStatus =
| 'PAY_SUCCESS'
| 'PAY_FAILED'
| 'WAIT_PAY'
| 'ORDER_CLOSED'
| 'PAYING'
| 'ACCEPTED'
| 'REFUNDING'
| 'REFUND_SUCCESS'
| 'REFUND_FAILED';
export interface QueryOrderResponse {
result?: 'SUCCESS' | 'FAIL';
code?: string;
msg?: string;
nonce_str?: string;
sign?: string;
sign_type?: string;
biz_content?: {
merch_order_id?: string;
order_status?: string;
trade_status?: TelebirrTradeStatus | string;
payment_order_id?: string;
trans_id?: string;
trans_time?: string;
trans_currency?: string;
total_amount?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}

View File

@@ -0,0 +1,13 @@
export class TelebirrDto {
merch_order_id!: string;
payment_order_id!: string;
trade_status!: string;
trans_id?: string;
total_amount?: string;
trans_currency?: string;
notify_time?: string;
trans_end_time?: string;
sign!: string;
sign_type?: string;
[key: string]: unknown;
}

View File

@@ -0,0 +1,49 @@
import { Injectable, } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from "crypto"
import { TelebirrDto } from '../dto/telebirr.dto';
@Injectable()
export class TelebirrWebhookService {
// private readonly logger = new Logger(TelebirrWebhookService.name);
constructor(
private readonly config: ConfigService
) { }
verifyTelebirrNotification(payload: TelebirrDto) {
// 1. Extract the signature provided by Telebirr
const { sign, ...bizContent } = payload;
if (!sign) {
throw new Error("Missing 'sign' field from Telebirr payload");
}
// 2. Sort the remaining keys alphabetically to rebuild the raw string
const sortedKeys = Object.keys(bizContent).sort();
const signString = sortedKeys
.map(key => `${key}=${typeof bizContent[key] === 'object' ? JSON.stringify(bizContent[key]) : bizContent[key]}`)
.join('&');
// 3. Convert Telebirr's public key into an object specifying RSA-PSS padding
const publicKey = {
key: this.config.get<string>("telebirr.publicKey") ?? "",
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32 // Telebirr standard salt length
};
// 4. Verify the signature against the sorted string
const isVerified = crypto.verify(
"sha256",
Buffer.from(signString),
publicKey,
Buffer.from(sign, 'base64')
);
return isVerified;
}
async handle(payload: TelebirrDto): Promise<void> {
console.log(payload)
}
}

View File

@@ -0,0 +1,41 @@
import { All, Body, Controller, HttpCode, HttpStatus, Logger, } from '@nestjs/common';
import { TelebirrWebhookService } from './providers/telebirr.service';
import { ApiOperation } from '@nestjs/swagger';
import { TelebirrDto } from './dto/telebirr.dto';
@Controller("payments/webhooks")
export class WebhookController {
constructor(private readonly telebirr: TelebirrWebhookService) { }
private readonly logger = new Logger(WebhookController.name);
@All('telebirr')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Telebirr payment notification callback (Ethiopia)',
description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.'
})
async receiveTelebirr(@Body() payload: TelebirrDto) {
this.logger.log(
`Telebirr webhook Called`,
);
try {
const verified = this.telebirr.verifyTelebirrNotification(payload)
if (!verified) {
throw new Error("not valid")
}
const merchantOrderId = payload.merch_order_id;
if (merchantOrderId.startsWith("freight")) {
await this.telebirr.handle(payload);
} else if (merchantOrderId.startsWith("passagner")) {
//todo: handle else where
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Telebirr webhook handler threw: ${message}`);
}
return { code: '0', message: 'OK' };
}
}

View File

@@ -82,17 +82,6 @@ export class TrainSchedulingService {
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
const bookingRepository = this.dataSource.getRepository(Booking);
const queryBuilder = bookingRepository
<<<<<<< HEAD
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoin(TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id')
.where('booking.freightType = :freightType', { freightType: 'CONTAINER' })
.andWhere('scheduleBooking.id IS NULL');
=======
.createQueryBuilder("booking")
.leftJoinAndSelect("booking.customer", "customer")
.leftJoinAndSelect("booking.originYard", "originYard")
@@ -106,7 +95,6 @@ export class TrainSchedulingService {
)
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
.andWhere("scheduleBooking.id IS NULL");
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
if (query.originStationId) {
queryBuilder.andWhere("booking.originYardId = :originStationId", {
@@ -144,13 +132,6 @@ export class TrainSchedulingService {
const items: EligibleBookingItem[] = bookings.map((booking) => ({
id: booking.id,
reference: booking.reference,
<<<<<<< HEAD
customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer',
containerType: booking.bookingContainers
?.map((container) => container.containerType?.label ?? container.containerType?.code ?? 'Container')
.join(', ') ?? 'Container',
quantity: booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0,
=======
customer:
booking.company?.name ?? booking.company?.email ?? "Unknown customer",
containerType:
@@ -167,7 +148,6 @@ export class TrainSchedulingService {
(sum, container) => sum + Number(container.quantity ?? 0),
0,
) ?? 0,
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
weightTons: this.roundTons(booking.cargoTotalWeightVgm),
origin:
booking.originYard?.label ??
@@ -674,17 +654,6 @@ export class TrainSchedulingService {
}
async getContainerTrainScheduleById(id: string) {
<<<<<<< HEAD
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
where: { id },
relations: {
trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } } },
originStation: true,
destinationStation: true,
scheduleBookings: { booking: { company: true, originYard: true, destinationYard: true } },
},
});
=======
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({
@@ -701,7 +670,6 @@ export class TrainSchedulingService {
},
},
});
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);

View File

@@ -1,449 +1,297 @@
import type { ReactNode } from "react";
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import {
Boxes,
FileText,
LayoutDashboard,
Network,
Paperclip,
Settings,
SlidersHorizontal,
Train,
Truck,
Container,
Package,
//TrainTrack,
} from "lucide-react";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
import LoadingScreen from "./components/LoadingScreen";
import { useAuth } from "./auth/useAuth";
import {
canAccessBookings,
canAccessRuleEngineResource,
hasPermission,
} from "@/lib/permissions";
import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
import UsersPage from "./pages/dashboard/user-management/UsersPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
<<<<<<< HEAD
import {
getCategorySidebarChildren,
RULE_ENGINE_RESOURCES,
type RuleEngineNavCategory,
} from "./pages/ruleEngine/config/resources";
import type { RuleEngineResourceSlug } from "./types/rule-engine";
const filterRuleEngineChildren = (
=======
import TrainsPage from "./pages/trains/TrainsPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
//import TrainsPage from "./pages/trains/TrainsPage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import WagonsPage from "./pages/wagons/WagonsPage";
import ContainersPage from "./pages/containers_management/ContainersPage";
import CargoesPage from "./pages/cargoes/CargoesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
mutedTitle: true,
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
{
label: "Train scheduling",
href: "/dashboard/operations/train-scheduling",
icon: <Train />,
},
...demoItems,
],
},
{
title: "Fleet Management",
items: [
{
label: "Trains",
href: "/dashboard/trains",
icon: <Train />,
},
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
},
{
label: "Containers",
href: "/dashboard/containers",
icon: <Container />,
},
{
label: "Cargoes",
href: "/dashboard/cargoes",
icon: <Package />,
},
],
},
{
title: "Administration",
items: [
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
children: [
{
label: "Users",
href: "/dashboard/user-management/users",
},
{
label: "Employees",
href: "/dashboard/user-management/employees",
},
{
label: "Position Types",
href: "/dashboard/user-management/position-types",
},
{
label: "Permissions",
href: "/dashboard/user-management/permissions",
},
{
label: "Roles",
href: "/dashboard/user-management/roles",
},
],
},
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: getCategorySidebarChildren("configuration"),
},
{
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
],
},
];
const hasPermission = (
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
user: ReturnType<typeof useAuth>["user"],
category: RuleEngineNavCategory,
): SidebarItem[] =>
getCategorySidebarChildren(category).filter((item) => {
const slug = item.href.split("/").pop() as RuleEngineResourceSlug;
return canAccessRuleEngineResource(user, slug, "view");
});
const buildSidebarSections = (
user: ReturnType<typeof useAuth>["user"],
demoItems: SidebarItem[],
): SidebarSection[] => {
const configurationChildren = filterRuleEngineChildren(user, "configuration");
const rulesChildren = filterRuleEngineChildren(user, "rules");
const mainItems: SidebarItem[] = [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
...(canAccessBookings(user)
? [
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
]
: []),
...demoItems,
];
const freightConfigItems: SidebarItem[] = [];
if (configurationChildren.length) {
freightConfigItems.push({
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: configurationChildren,
});
}
if (rulesChildren.length) {
freightConfigItems.push({
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: rulesChildren,
});
}
const sections: SidebarSection[] = [
{ title: "Main menu", mutedTitle: true, items: mainItems },
{
title: "Administration",
items: [
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
children: [
{ label: "Users", href: "/dashboard/user-management/users" },
{ label: "Position Types", href: "/dashboard/user-management/position-types" },
{ label: "Permissions", href: "/dashboard/user-management/permissions" },
{ label: "Roles", href: "/dashboard/user-management/roles" },
],
},
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
},
],
},
];
if (freightConfigItems.length) {
sections.push({
title: "Freight configuration",
mutedTitle: true,
items: freightConfigItems,
});
}
return sections;
};
const PermissionRoute = ({
allow,
children,
}: {
allow: boolean;
children: ReactNode;
}) => (allow ? children : <Navigate to="/dashboard/overview" replace />);
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const demoItems: SidebarItem[] = [
...(hasPermission(user, "can:demo:user1")
? [{ label: "User1", href: "/dashboard/user1", icon: <Settings /> }]
: []),
...(hasPermission(user, "can:demo:user2")
? [{ label: "User2", href: "/dashboard/user2", icon: <Settings /> }]
: []),
];
const sidebarSections = buildSidebarSections(user, demoItems);
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
<FreightDashboardLayout
sidebarSections={sidebarSections}
activeHref={location.pathname}
onNavigate={navigate}
enableThemeToggle
userName={displayName}
userEmail={user?.email}
onLogout={logout}
>
<Outlet />
</FreightDashboardLayout>
);
};
const ruleEngineSlugs = RULE_ENGINE_RESOURCES.map((r) => r.slug);
const App = () => {
const { user, loading } = useAuth();
if (loading) {
return <LoadingScreen />;
}
if (!user) {
return (
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
}
const canBookings = canAccessBookings(user);
return (
<Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<<<<<<< HEAD
<Route
path="booking-requests"
element={
<PermissionRoute allow={canBookings}>
<BookingRequestsPage />
</PermissionRoute>
}
/>
<Route
path="booking-requests/:id"
element={
<PermissionRoute allow={canBookings}>
<BookingRequestDetailPage />
</PermissionRoute>
}
/>
<Route
path="booking-requests/:id/contract"
element={
<PermissionRoute allow={canBookings}>
<BookingContractPage />
</PermissionRoute>
}
/>
=======
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="operations/train-scheduling" element={<TrainsPage />} />
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
<<<<<<< HEAD
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
=======
<Route path="trains" element={<TrainsPage />} />
<Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="wagons" element={<WagonsPage />} />
<Route path="containers" element={<ContainersPage />} />
<Route path="cargoes" element={<CargoesPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
>>>>>>> ec3f83c9fb517bc0302e315bd3e35db501483751
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route
path="configuration/:resource"
element={
<PermissionRoute
allow={ruleEngineSlugs.some((slug) =>
canAccessRuleEngineResource(user, slug, "view"),
)}
>
<RuleEngineResourcePage />
</PermissionRoute>
}
/>
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
/>
<Route
path="rules/:resource"
element={
<PermissionRoute
allow={ruleEngineSlugs.some((slug) =>
canAccessRuleEngineResource(user, slug, "view"),
)}
>
<RuleEngineResourcePage />
</PermissionRoute>
}
/>
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route
path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />}
/>
<Route
path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />}
/>
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
);
};
export default App;
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import {
Boxes,
FileText,
LayoutDashboard,
Network,
Paperclip,
Settings,
SlidersHorizontal,
Train,
Truck,
Container,
Package,
//TrainTrack,
} from "lucide-react";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
import LoadingScreen from "./components/LoadingScreen";
import { useAuth } from "./auth/useAuth";
import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage";
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
import UsersPage from "./pages/dashboard/user-management/UsersPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
// import TrainsPage from "./pages/trains/TrainsPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
//import TrainsPage from "./pages/trains/TrainsPage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import WagonsPage from "./pages/wagons/WagonsPage";
import ContainersPage from "./pages/containers_management/ContainersPage";
import CargoesPage from "./pages/cargoes/CargoesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
mutedTitle: true,
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
{
label: "Train scheduling",
href: "/dashboard/operations/train-scheduling",
icon: <Train />,
},
...demoItems,
],
},
{
title: "Fleet Management",
items: [
{
label: "Trains",
href: "/dashboard/trains",
icon: <Train />,
},
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
},
{
label: "Containers",
href: "/dashboard/containers",
icon: <Container />,
},
{
label: "Cargoes",
href: "/dashboard/cargoes",
icon: <Package />,
},
],
},
{
title: "Administration",
items: [
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
children: [
{
label: "Users",
href: "/dashboard/user-management/users",
},
{
label: "Employees",
href: "/dashboard/user-management/employees",
},
{
label: "Position Types",
href: "/dashboard/user-management/position-types",
},
{
label: "Permissions",
href: "/dashboard/user-management/permissions",
},
{
label: "Roles",
href: "/dashboard/user-management/roles",
},
],
},
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: getCategorySidebarChildren("configuration"),
},
{
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
],
},
];
const hasPermission = (
user: ReturnType<typeof useAuth>["user"],
key: string,
) => {
if (!user) return false;
if (user.permissions?.some((p) => p.key === key)) return true;
return (user.employee ?? []).some((emp) =>
(emp.positions ?? []).some((pos) =>
(pos.permissions ?? []).some((p) => p.key === key),
),
);
};
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const demoItems: SidebarItem[] = [
...(hasPermission(user, "can:demo:user1")
? [
{
label: "User1",
href: "/dashboard/user1",
icon: <Settings />,
},
]
: []),
...(hasPermission(user, "can:demo:user2")
? [
{
label: "User2",
href: "/dashboard/user2",
icon: <Settings />,
},
]
: []),
];
const sidebarSections = buildSidebarSections(demoItems);
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
<FreightDashboardLayout
sidebarSections={sidebarSections}
activeHref={location.pathname}
onNavigate={navigate}
enableThemeToggle
userName={displayName}
userEmail={user?.email}
onLogout={logout}
>
<Outlet />
</FreightDashboardLayout>
);
};
const App = () => {
const { user, loading } = useAuth();
if (loading) {
return <LoadingScreen />;
}
if (!user) {
return (
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
}
return (
<Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
{/* <Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="trains" element={<TrainsPage />} /> */}
<Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="wagons" element={<WagonsPage />} />
<Route path="containers" element={<ContainersPage />} />
<Route path="cargoes" element={<CargoesPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route
path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />}
/>
<Route
path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />}
/>
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
);
};
export default App;

View File

@@ -1,19 +1,35 @@
import { useMemo } from "react";
import { ShieldCheck } from "lucide-react";
import { useMemo, useState } from "react";
import { Check, ShieldCheck } from "lucide-react";
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useAuth } from "@/auth/useAuth";
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
import {
buildApproveActionForStep,
canActOnApprovalStep,
getNextPendingApprovalStep,
} from "@/features/bookings/booking-actions.config";
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
import { Badge } from "@edr/ui-common";
import { Badge, Button } from "@edr/ui-common";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>;
interface ApprovalStepsCardProps {
booking: BookingDetail;
mutations: Mutations;
}
/** Read-only approval chain visualization; actions live in BookingActionsToolbar. */
export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
/** Approval chain with inline approve on the current pending step. */
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
const { user } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(
null,
);
const steps = useMemo(
() =>
[...(booking.approvalSteps ?? [])].sort(
@@ -24,57 +40,110 @@ export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
const nextPending = getNextPendingApprovalStep(steps);
const summary = formatApprovalProgress(booking.status, steps);
const pendingAction = pendingStep
? buildApproveActionForStep(pendingStep)
: null;
const openApprove = (step: BookingApprovalStep) => {
setPendingStep(step);
setConfirmOpen(true);
};
const closeApprove = () => {
setConfirmOpen(false);
setPendingStep(null);
};
const runApprove = () => {
if (!pendingStep) return;
mutations.approveStep.mutate(
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
{ onSuccess: () => closeApprove() },
);
};
return (
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
<div className={bookingSurface.sectionHeader}>
<div className={bookingSurface.sectionIcon}>
<ShieldCheck className="size-4" strokeWidth={1.75} />
<>
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
<div className={bookingSurface.sectionHeader}>
<div className={bookingSurface.sectionIcon}>
<ShieldCheck className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Approval chain
</h2>
<p className="text-xs text-muted-foreground">
{summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin")}
</p>
</div>
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Approval chain
</h2>
<p className="text-xs text-muted-foreground">
{summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin")}
</p>
<div className="px-5 py-5">
{steps.length === 0 ? (
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm">
Use{" "}
<strong className="font-semibold text-foreground">
Accept for approval
</strong>{" "}
in staff actions to instantiate steps.
</p>
) : (
<ul className="space-y-2">
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
steps={steps}
user={user}
isNext={nextPending?.id === step.id}
isPending={mutations.approveStep.isPending}
onApprove={openApprove}
/>
))}
</ul>
)}
</div>
</div>
<div className="px-5 py-5">
{steps.length === 0 ? (
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm">
Use <strong className="font-semibold text-foreground">Accept for approval</strong>{" "}
in staff actions to instantiate steps.
</p>
) : (
<ul className="space-y-2">
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
/>
))}
</ul>
)}
</div>
</div>
<BookingConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
if (!open) closeApprove();
else setConfirmOpen(true);
}}
action={pendingAction}
reference={booking.reference}
inputValue=""
onInputChange={() => {}}
onConfirm={runApprove}
isPending={mutations.approveStep.isPending}
/>
</>
);
}
function StepRow({
step,
steps,
user,
isNext,
isPending,
onApprove,
}: {
step: BookingApprovalStep;
steps: BookingApprovalStep[];
user: ReturnType<typeof useAuth>["user"];
isNext: boolean;
isPending: boolean;
onApprove: (step: BookingApprovalStep) => void;
}) {
const canApprove = canActOnApprovalStep(user, step, steps);
const statusStyles =
step.status === "APPROVED"
? "border-emerald-500/25 bg-emerald-500/10 text-black"
@@ -88,9 +157,7 @@ function StepRow({
<li
className={cn(
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
isNext
? bookingGlass.activeTab
: "border-border/50 bg-card/60",
isNext ? bookingGlass.activeTab : "border-border/50 bg-card/60",
)}
>
<div className="flex min-w-0 items-center gap-3">
@@ -115,12 +182,26 @@ function StepRow({
)}
</div>
</div>
<Badge
variant="outline"
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
>
{step.status}
</Badge>
<div className="flex shrink-0 items-center gap-2">
{canApprove && (
<Button
type="button"
size="sm"
className="h-8 gap-1.5 shadow-sm"
disabled={isPending}
onClick={() => onApprove(step)}
>
<Check className="size-3.5" />
Approve
</Button>
)}
<Badge
variant="outline"
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
>
{step.status}
</Badge>
</div>
</li>
);
}

View File

@@ -1,12 +1,13 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useWagons, useAssignWagonToTrain } from '@/hooks/useWagons';
import { useToast } from '@/hooks/use-toast';
import { Plus } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
export function AssignWagonDialog({ trainId }: { trainId: string }) {
const [open, setOpen] = useState(false);
@@ -16,7 +17,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
const assign = useAssignWagonToTrain();
const { toast } = useToast();
const available = wagons?.filter(w => w.status === 'AVAILABLE' || !w.trainId);
const available = wagons?.filter((w:any) => w.status === 'AVAILABLE' || !w.trainId);
const handleAssign = async () => {
if (!wagonId) return;
@@ -36,7 +37,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
<Select value={wagonId} onValueChange={setWagonId}>
<SelectTrigger><SelectValue placeholder="Select wagon" /></SelectTrigger>
<SelectContent>
{available?.map(w => <SelectItem key={w.id} value={w.id}>{w.wagonNumber}</SelectItem>)}
{available?.map((w:any) => <SelectItem key={w.id} value={w.id}>{w.wagonNumber}</SelectItem>)}
</SelectContent>
</Select>
</div>

View File

@@ -2,7 +2,7 @@ import { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/us
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Trash2, GripVertical } from 'lucide-react';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
// import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
export function WagonsTable({ trainId }: { trainId: string }) {
const { data: wagons, refetch } = useWagonsByTrain(trainId);
@@ -14,50 +14,51 @@ export function WagonsTable({ trainId }: { trainId: string }) {
const items = Array.from(wagons || []);
const [removed] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, removed);
reorder.mutate({ trainId, wagonIds: items.map(w => w.id) });
reorder.mutate({ trainId, wagonIds: items.map((w:any) => w.id) });
};
if (!wagons?.length) return <div className="text-muted-foreground">No wagons assigned.</div>;
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="wagons">
{(provided) => (
<Table {...provided.droppableProps} ref={provided.innerRef}>
<TableHeader>
<TableRow>
<TableHead className="w-10"></TableHead>
<TableHead>Number</TableHead>
<TableHead>Type</TableHead>
<TableHead>Sequence</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wagons.map((wagon, idx) => (
<Draggable key={wagon.id} draggableId={wagon.id} index={idx}>
{(provided) => (
<TableRow ref={provided.innerRef} {...provided.draggableProps}>
<TableCell {...provided.dragHandleProps}><GripVertical className="h-4 w-4 cursor-grab" /></TableCell>
<TableCell>{wagon.wagonNumber}</TableCell>
<TableCell>{wagon.wagonTypeId}</TableCell>
<TableCell>{wagon.sequenceNumber}</TableCell>
<TableCell>{wagon.status}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => unassign.mutateAsync(wagon.id).then(() => refetch())}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
)}
</Draggable>
))}
{provided.placeholder}
</TableBody>
</Table>
)}
</Droppable>
</DragDropContext>
<div></div>
// <DragDropContext onDragEnd={onDragEnd}>
// <Droppable droppableId="wagons">
// {(provided) => (
// <Table {...provided.droppableProps} ref={provided.innerRef}>
// <TableHeader>
// <TableRow>
// <TableHead className="w-10"></TableHead>
// <TableHead>Number</TableHead>
// <TableHead>Type</TableHead>
// <TableHead>Sequence</TableHead>
// <TableHead>Status</TableHead>
// <TableHead>Actions</TableHead>
// </TableRow>
// </TableHeader>
// <TableBody>
// {wagons.map((wagon, idx) => (
// <Draggable key={wagon.id} draggableId={wagon.id} index={idx}>
// {(provided) => (
// <TableRow ref={provided.innerRef} {...provided.draggableProps}>
// <TableCell {...provided.dragHandleProps}><GripVertical className="h-4 w-4 cursor-grab" /></TableCell>
// <TableCell>{wagon.wagonNumber}</TableCell>
// <TableCell>{wagon.wagonTypeId}</TableCell>
// <TableCell>{wagon.sequenceNumber}</TableCell>
// <TableCell>{wagon.status}</TableCell>
// <TableCell>
// <Button variant="ghost" size="icon" onClick={() => unassign.mutateAsync(wagon.id).then(() => refetch())}>
// <Trash2 className="h-4 w-4" />
// </Button>
// </TableCell>
// </TableRow>
// )}
// </Draggable>
// ))}
// {provided.placeholder}
// </TableBody>
// </Table>
// )}
// </Droppable>
// </DragDropContext>
);
}

View File

@@ -13,7 +13,11 @@ import {
} from "lucide-react";
import type { AuthUser } from "@/auth/types";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
FREIGHT_PERMS,
hasPermission,
isFreightApprovalAdmin,
} from "@/lib/permissions";
import type {
BookingApprovalStep,
BookingDetail,
@@ -71,18 +75,7 @@ function approvalActions(
const next = getNextPendingApprovalStep(steps);
if (!next) return [];
return [
{
id: "approve",
label: `Approve (${next.requiredRole})`,
shortLabel: "Approve",
description: `Complete step ${next.stepOrder} as ${next.requiredRole}`,
confirmTitle: `Approve as ${next.requiredRole}?`,
confirmDescription:
"This records your approval and advances the booking to the next step in the chain.",
variant: "default",
icon: Check,
primary: true,
},
buildApproveActionForStep(next),
{
id: "rejectApproval",
label: "Reject approval",
@@ -219,6 +212,37 @@ const approvePermissionForRole = (role: string): string | undefined => {
return undefined;
};
/** True when this step is the current pending step and the user may approve it. */
export function canActOnApprovalStep(
user: AuthUser | null | undefined,
step: BookingApprovalStep,
steps?: BookingApprovalStep[] | null,
): boolean {
if (step.status !== "PENDING") return false;
const next = getNextPendingApprovalStep(steps);
if (!next || next.id !== step.id) return false;
if (isFreightApprovalAdmin(user)) return true;
const perm = approvePermissionForRole(step.requiredRole);
return perm ? hasPermission(user, perm) : false;
}
export function buildApproveActionForStep(
step: BookingApprovalStep,
): BookingActionDef {
return {
id: "approve",
label: `Approve (${step.requiredRole})`,
shortLabel: "Approve",
description: `Complete step ${step.stepOrder} as ${step.requiredRole}`,
confirmTitle: `Approve as ${step.requiredRole}?`,
confirmDescription:
"This records your approval and advances the booking to the next step in the chain.",
variant: "default",
icon: Check,
primary: true,
};
}
function filterActionsByUser(
actions: BookingActionDef[],
user: AuthUser | null | undefined,
@@ -228,8 +252,7 @@ function filterActionsByUser(
const next = getNextPendingApprovalStep(approvalSteps);
return actions.filter((action) => {
if (action.id === "approve" && next) {
const perm = approvePermissionForRole(next.requiredRole);
return perm ? hasPermission(user, perm) : false;
return canActOnApprovalStep(user, next, approvalSteps);
}
const perm = ACTION_PERMISSION[action.id];
return perm ? hasPermission(user, perm) : true;

View File

@@ -44,6 +44,19 @@ export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
}
/** Org-level admins may act on any approval step in the chain. */
export function isOrganizationAdmin(
user: AuthUser | null | undefined,
): boolean {
return Boolean(user?.roles?.some((r) => r.key === "organization_admin"));
}
export function isFreightApprovalAdmin(
user: AuthUser | null | undefined,
): boolean {
return isSuperAdmin(user) || isOrganizationAdmin(user);
}
export function hasPermission(
user: AuthUser | null | undefined,
key: string,

View File

@@ -258,7 +258,7 @@ export default function BookingRequestDetailPage() {
)}
{(booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE") && (
<ApprovalStepsCard booking={booking} />
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
</div>
</div>

View File

@@ -1,8 +1,9 @@
import { useCargoes } from '@/hooks/useCargoes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { LoadCargoDialog } from '@/components/cargoes/LoadCargoDialog';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function CargoesPage() {
const { data: cargoes, refetch, isLoading } = useCargoes();
@@ -14,7 +15,7 @@ export default function CargoesPage() {
<Table>
<TableHeader><TableRow><TableHead>Reference</TableHead><TableHead>Description</TableHead><TableHead>Quantity</TableHead><TableHead>Weight</TableHead><TableHead>Status</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
<TableBody>
{cargoes?.map(c => (
{cargoes?.map((c:any) => (
<TableRow key={c.id}>
<TableCell>{c.cargoReference}</TableCell>
<TableCell>{c.description || '-'}</TableCell>

View File

@@ -1,7 +1,8 @@
import { useContainers } from '@/hooks/useContainers';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function ContainersPage() {
const { data: containers, isLoading } = useContainers();
@@ -13,7 +14,7 @@ export default function ContainersPage() {
<Table>
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Wagon</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{containers?.map(c => (
{containers?.map((c:any) => (
<TableRow key={c.id}>
<TableCell>{c.containerNumber}</TableCell>
<TableCell>{c.containerTypeId}</TableCell>

View File

@@ -1,9 +1,12 @@
import { useParams } from 'react-router-dom';
import { useTrain } from '@/hooks/useTrains';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { AssignWagonDialog } from '@/components/AssignWagonDialog';
import { WagonsTable } from '@/components/WagonsTable';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
// import { Skeleton } from '@/components/ui/skeleton';
// import { AssignWagonDialog } from '@/components/AssignWagonDialog';
// import { WagonsTable } from '@/components/WagonsTable';
import { Card, CardContent, CardHeader, CardTitle, Skeleton } from '@edr/ui-common';
import { AssignWagonDialog } from '@/components/wagons/AssignWagonDialog';
import { WagonsTable } from '@/components/wagons/WagonsTable';
export default function TrainDetailPage() {
const { id } = useParams<{ id: string }>();

View File

@@ -1,42 +1,3 @@
<<<<<<< HEAD
import { useState } from 'react';
import { useTrains, useDeleteTrain, useCreateTrain } from '@/hooks/useTrains';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { Plus, Eye, Trash2 } from 'lucide-react';
import { Link } from 'react-router-dom';
const CreateTrainForm = ({ onSuccess }: { onSuccess: () => void }) => {
const [form, setForm] = useState({ code: '', capacityTons: 0, trainNumber: '', trainName: '' });
const createTrain = useCreateTrain();
const { toast } = useToast();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createTrain.mutateAsync(form);
toast({ title: 'Train created', description: `${form.code} added.` });
onSuccess();
} catch {
toast({ title: 'Error', description: 'Failed to create train.', variant: 'destructive' });
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div><Label>Code*</Label><Input required value={form.code} onChange={e => setForm({...form, code: e.target.value})} /></div>
<div><Label>Capacity (tons)*</Label><Input type="number" required value={form.capacityTons} onChange={e => setForm({...form, capacityTons: parseFloat(e.target.value)})} /></div>
<div><Label>Train Number</Label><Input value={form.trainNumber} onChange={e => setForm({...form, trainNumber: e.target.value})} /></div>
<div><Label>Train Name</Label><Input value={form.trainName} onChange={e => setForm({...form, trainName: e.target.value})} /></div>
<Button type="submit" disabled={createTrain.isPending}>Save</Button>
</form>
=======
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
@@ -794,45 +755,44 @@ const TrainsPage = () => {
</DialogContent>
</Dialog>
</div>
>>>>>>> 523d7e58422f1bde8024c2dd237092a7cf6aa190
);
};
export default function TrainsPage() {
const { data: trains, isLoading } = useTrains();
const deleteTrain = useDeleteTrain();
const [open, setOpen] = useState(false);
// export default function TrainsPage() {
// const { data: trains, isLoading } = useTrains();
// const deleteTrain = useDeleteTrain();
// const [open, setOpen] = useState(false);
if (isLoading) return <div className="p-8">Loading trains...</div>;
// if (isLoading) return <div className="p-8">Loading trains...</div>;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Trains</CardTitle>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />New Train</Button></DialogTrigger>
<DialogContent><DialogHeader><DialogTitle>Create Train</DialogTitle></DialogHeader><CreateTrainForm onSuccess={() => setOpen(false)} /></DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Name</TableHead><TableHead>Status</TableHead><TableHead>Capacity</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
<TableBody>
{trains?.map(train => (
<TableRow key={train.id}>
<TableCell>{train.trainNumber || train.code}</TableCell>
<TableCell>{train.trainName || '-'}</TableCell>
<TableCell><Badge variant="outline">{train.status}</Badge></TableCell>
<TableCell>{train.capacityTons} t</TableCell>
<TableCell className="flex space-x-2">
<Link to={`/trains/${train.id}`}><Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button></Link>
<Button variant="ghost" size="icon" onClick={() => deleteTrain.mutate(train.id)}><Trash2 className="h-4 w-4" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
// return (
// <Card>
// <CardHeader className="flex flex-row items-center justify-between">
// <CardTitle>Trains</CardTitle>
// <Dialog open={open} onOpenChange={setOpen}>
// <DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />New Train</Button></DialogTrigger>
// <DialogContent><DialogHeader><DialogTitle>Create Train</DialogTitle></DialogHeader><CreateTrainForm onSuccess={() => setOpen(false)} /></DialogContent>
// </Dialog>
// </CardHeader>
// <CardContent>
// <Table>
// <TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Name</TableHead><TableHead>Status</TableHead><TableHead>Capacity</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
// <TableBody>
// {trains?.map(train => (
// <TableRow key={train.id}>
// <TableCell>{train.trainNumber || train.code}</TableCell>
// <TableCell>{train.trainName || '-'}</TableCell>
// <TableCell><Badge variant="outline">{train.status}</Badge></TableCell>
// <TableCell>{train.capacityTons} t</TableCell>
// <TableCell className="flex space-x-2">
// <Link to={`/trains/${train.id}`}><Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button></Link>
// <Button variant="ghost" size="icon" onClick={() => deleteTrain.mutate(train.id)}><Trash2 className="h-4 w-4" /></Button>
// </TableCell>
// </TableRow>
// ))}
// </TableBody>
// </Table>
// </CardContent>
// </Card>
// );
// }

View File

@@ -1,7 +1,9 @@
import { useWagons } from '@/hooks/useWagons';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function WagonsPage() {
const { data: wagons, isLoading } = useWagons();
@@ -13,7 +15,7 @@ export default function WagonsPage() {
<Table>
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Train</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{wagons?.map(w => (
{wagons?.map((w:any) => (
<TableRow key={w.id}>
<TableCell>{w.wagonNumber}</TableCell>
<TableCell>{w.wagonTypeId}</TableCell>

View File

@@ -1,4 +1,6 @@
import { apiClient } from '@/lib/axios';
// import { apiClient } from '@/lib/axios';
import { api as apiClient } from "../auth/http";
export interface Cargo {
id: string;

View File

@@ -1,4 +1,6 @@
import { apiClient } from '@/lib/axios';
// import { apiClient } from '@/lib/axios';
import { api as apiClient } from "../auth/http";
export interface Container {
id: string;

View File

@@ -1,4 +1,6 @@
import { apiClient } from '@/lib/axios';
// import { apiClient } from '@/lib/axios';
import { api as apiClient } from "../auth/http";
export interface Train {
id: string;

View File

@@ -1,4 +1,4 @@
import { apiClient } from '@/lib/axios';
import { api as apiClient } from "../auth/http";
export interface Wagon {
id: string;

2086
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff