mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
resolve confilict
This commit is contained in:
@@ -38,6 +38,7 @@ import {
|
|||||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-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 { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
|
||||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||||
@@ -92,6 +93,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
|||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
DemoPermissionsModule,
|
DemoPermissionsModule,
|
||||||
FreightAuthModule,
|
FreightAuthModule,
|
||||||
|
PaymentModule,
|
||||||
//New Modules
|
//New Modules
|
||||||
TrainsModule,
|
TrainsModule,
|
||||||
WagonsModule,
|
WagonsModule,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
|
|||||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||||
|
|
||||||
const SUPER_ADMIN_ROLE = 'super_admin';
|
const SUPER_ADMIN_ROLE = 'super_admin';
|
||||||
|
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
|
||||||
|
|
||||||
type PermissionLike = { key?: string };
|
type PermissionLike = { key?: string };
|
||||||
type MeLikeUser = {
|
type MeLikeUser = {
|
||||||
@@ -25,6 +26,15 @@ export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
|
|||||||
return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE);
|
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). */
|
/** Flat permission keys from JWT / session user (roles + position permissions). */
|
||||||
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
||||||
if (!user) return [];
|
if (!user) return [];
|
||||||
@@ -90,7 +100,7 @@ export function assertCanApproveBookingStep(
|
|||||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||||
requiredRole: string,
|
requiredRole: string,
|
||||||
): void {
|
): void {
|
||||||
if (isSuperAdmin(user)) return;
|
if (isFreightApprovalAdmin(user)) return;
|
||||||
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
|
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
|
||||||
if (!perm) {
|
if (!perm) {
|
||||||
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);
|
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
16
apps/edr-freight-api/src/modules/payment/payment.module.ts
Normal file
16
apps/edr-freight-api/src/modules/payment/payment.module.ts
Normal 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 { }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
113
apps/edr-freight-api/src/modules/payment/payment.service.ts
Normal file
113
apps/edr-freight-api/src/modules/payment/payment.service.ts
Normal 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;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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')}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -82,17 +82,6 @@ export class TrainSchedulingService {
|
|||||||
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
|
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
|
||||||
const bookingRepository = this.dataSource.getRepository(Booking);
|
const bookingRepository = this.dataSource.getRepository(Booking);
|
||||||
const queryBuilder = bookingRepository
|
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")
|
.createQueryBuilder("booking")
|
||||||
.leftJoinAndSelect("booking.customer", "customer")
|
.leftJoinAndSelect("booking.customer", "customer")
|
||||||
.leftJoinAndSelect("booking.originYard", "originYard")
|
.leftJoinAndSelect("booking.originYard", "originYard")
|
||||||
@@ -106,7 +95,6 @@ export class TrainSchedulingService {
|
|||||||
)
|
)
|
||||||
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
|
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
|
||||||
.andWhere("scheduleBooking.id IS NULL");
|
.andWhere("scheduleBooking.id IS NULL");
|
||||||
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
|
|
||||||
|
|
||||||
if (query.originStationId) {
|
if (query.originStationId) {
|
||||||
queryBuilder.andWhere("booking.originYardId = :originStationId", {
|
queryBuilder.andWhere("booking.originYardId = :originStationId", {
|
||||||
@@ -144,13 +132,6 @@ export class TrainSchedulingService {
|
|||||||
const items: EligibleBookingItem[] = bookings.map((booking) => ({
|
const items: EligibleBookingItem[] = bookings.map((booking) => ({
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
reference: booking.reference,
|
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:
|
customer:
|
||||||
booking.company?.name ?? booking.company?.email ?? "Unknown customer",
|
booking.company?.name ?? booking.company?.email ?? "Unknown customer",
|
||||||
containerType:
|
containerType:
|
||||||
@@ -167,7 +148,6 @@ export class TrainSchedulingService {
|
|||||||
(sum, container) => sum + Number(container.quantity ?? 0),
|
(sum, container) => sum + Number(container.quantity ?? 0),
|
||||||
0,
|
0,
|
||||||
) ?? 0,
|
) ?? 0,
|
||||||
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
|
|
||||||
weightTons: this.roundTons(booking.cargoTotalWeightVgm),
|
weightTons: this.roundTons(booking.cargoTotalWeightVgm),
|
||||||
origin:
|
origin:
|
||||||
booking.originYard?.label ??
|
booking.originYard?.label ??
|
||||||
@@ -674,17 +654,6 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getContainerTrainScheduleById(id: string) {
|
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
|
const schedule = await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.findOne({
|
.findOne({
|
||||||
@@ -701,7 +670,6 @@ export class TrainSchedulingService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
|
|
||||||
|
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||||
|
|||||||
@@ -1,449 +1,297 @@
|
|||||||
import type { ReactNode } from "react";
|
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
import {
|
||||||
import {
|
Boxes,
|
||||||
Boxes,
|
FileText,
|
||||||
FileText,
|
LayoutDashboard,
|
||||||
LayoutDashboard,
|
Network,
|
||||||
Network,
|
Paperclip,
|
||||||
Paperclip,
|
Settings,
|
||||||
Settings,
|
SlidersHorizontal,
|
||||||
SlidersHorizontal,
|
Train,
|
||||||
Train,
|
Truck,
|
||||||
Truck,
|
Container,
|
||||||
Container,
|
Package,
|
||||||
Package,
|
//TrainTrack,
|
||||||
//TrainTrack,
|
} from "lucide-react";
|
||||||
} from "lucide-react";
|
|
||||||
|
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
||||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
import LoadingScreen from "./components/LoadingScreen";
|
import { useAuth } from "./auth/useAuth";
|
||||||
import { useAuth } from "./auth/useAuth";
|
import LoginPage from "./pages/auth/LoginPage";
|
||||||
import {
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
canAccessBookings,
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
canAccessRuleEngineResource,
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
hasPermission,
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
} from "@/lib/permissions";
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import LoginPage from "./pages/auth/LoginPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
||||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
// import TrainsPage from "./pages/trains/TrainsPage";
|
||||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
//import TrainsPage from "./pages/trains/TrainsPage";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import WagonsPage from "./pages/wagons/WagonsPage";
|
||||||
<<<<<<< HEAD
|
import ContainersPage from "./pages/containers_management/ContainersPage";
|
||||||
import {
|
import CargoesPage from "./pages/cargoes/CargoesPage";
|
||||||
getCategorySidebarChildren,
|
|
||||||
RULE_ENGINE_RESOURCES,
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
type RuleEngineNavCategory,
|
{
|
||||||
} from "./pages/ruleEngine/config/resources";
|
title: "Main menu",
|
||||||
import type { RuleEngineResourceSlug } from "./types/rule-engine";
|
mutedTitle: true,
|
||||||
|
items: [
|
||||||
const filterRuleEngineChildren = (
|
{
|
||||||
=======
|
label: "Overview",
|
||||||
import TrainsPage from "./pages/trains/TrainsPage";
|
href: "/dashboard/overview",
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
icon: <LayoutDashboard />,
|
||||||
//import TrainsPage from "./pages/trains/TrainsPage";
|
},
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
{
|
||||||
import WagonsPage from "./pages/wagons/WagonsPage";
|
label: "Booking requests",
|
||||||
import ContainersPage from "./pages/containers_management/ContainersPage";
|
href: "/dashboard/booking-requests",
|
||||||
import CargoesPage from "./pages/cargoes/CargoesPage";
|
icon: <FileText />,
|
||||||
|
},
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
{
|
||||||
{
|
label: "Train scheduling",
|
||||||
title: "Main menu",
|
href: "/dashboard/operations/train-scheduling",
|
||||||
mutedTitle: true,
|
icon: <Train />,
|
||||||
items: [
|
},
|
||||||
{
|
...demoItems,
|
||||||
label: "Overview",
|
],
|
||||||
href: "/dashboard/overview",
|
},
|
||||||
icon: <LayoutDashboard />,
|
{
|
||||||
},
|
title: "Fleet Management",
|
||||||
{
|
items: [
|
||||||
label: "Booking requests",
|
{
|
||||||
href: "/dashboard/booking-requests",
|
label: "Trains",
|
||||||
icon: <FileText />,
|
href: "/dashboard/trains",
|
||||||
},
|
icon: <Train />,
|
||||||
{
|
},
|
||||||
label: "Train scheduling",
|
{
|
||||||
href: "/dashboard/operations/train-scheduling",
|
label: "Wagons",
|
||||||
icon: <Train />,
|
href: "/dashboard/wagons",
|
||||||
},
|
icon: <Truck />,
|
||||||
...demoItems,
|
},
|
||||||
],
|
{
|
||||||
},
|
label: "Containers",
|
||||||
{
|
href: "/dashboard/containers",
|
||||||
title: "Fleet Management",
|
icon: <Container />,
|
||||||
items: [
|
},
|
||||||
{
|
{
|
||||||
label: "Trains",
|
label: "Cargoes",
|
||||||
href: "/dashboard/trains",
|
href: "/dashboard/cargoes",
|
||||||
icon: <Train />,
|
icon: <Package />,
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
label: "Wagons",
|
},
|
||||||
href: "/dashboard/wagons",
|
{
|
||||||
icon: <Truck />,
|
title: "Administration",
|
||||||
},
|
items: [
|
||||||
{
|
{
|
||||||
label: "Containers",
|
label: "User management",
|
||||||
href: "/dashboard/containers",
|
href: "/dashboard/user-management",
|
||||||
icon: <Container />,
|
icon: <Network />,
|
||||||
},
|
children: [
|
||||||
{
|
{
|
||||||
label: "Cargoes",
|
label: "Users",
|
||||||
href: "/dashboard/cargoes",
|
href: "/dashboard/user-management/users",
|
||||||
icon: <Package />,
|
},
|
||||||
},
|
{
|
||||||
],
|
label: "Employees",
|
||||||
},
|
href: "/dashboard/user-management/employees",
|
||||||
{
|
},
|
||||||
title: "Administration",
|
{
|
||||||
items: [
|
label: "Position Types",
|
||||||
{
|
href: "/dashboard/user-management/position-types",
|
||||||
label: "User management",
|
},
|
||||||
href: "/dashboard/user-management",
|
{
|
||||||
icon: <Network />,
|
label: "Permissions",
|
||||||
children: [
|
href: "/dashboard/user-management/permissions",
|
||||||
{
|
},
|
||||||
label: "Users",
|
{
|
||||||
href: "/dashboard/user-management/users",
|
label: "Roles",
|
||||||
},
|
href: "/dashboard/user-management/roles",
|
||||||
{
|
},
|
||||||
label: "Employees",
|
],
|
||||||
href: "/dashboard/user-management/employees",
|
},
|
||||||
},
|
{
|
||||||
{
|
label: "File settings",
|
||||||
label: "Position Types",
|
href: "/dashboard/file-settings",
|
||||||
href: "/dashboard/user-management/position-types",
|
icon: <Paperclip />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Permissions",
|
label: "Dropdown settings",
|
||||||
href: "/dashboard/user-management/permissions",
|
href: "/dashboard/dropdown-settings",
|
||||||
},
|
icon: <Settings />,
|
||||||
{
|
},
|
||||||
label: "Roles",
|
],
|
||||||
href: "/dashboard/user-management/roles",
|
},
|
||||||
},
|
{
|
||||||
],
|
title: "Freight configuration",
|
||||||
},
|
mutedTitle: true,
|
||||||
{
|
items: [
|
||||||
label: "File settings",
|
{
|
||||||
href: "/dashboard/file-settings",
|
label: "Configuration",
|
||||||
icon: <Paperclip />,
|
href: "/dashboard/configuration",
|
||||||
},
|
icon: <Boxes />,
|
||||||
{
|
children: getCategorySidebarChildren("configuration"),
|
||||||
label: "Dropdown settings",
|
},
|
||||||
href: "/dashboard/dropdown-settings",
|
{
|
||||||
icon: <Settings />,
|
label: "Rules",
|
||||||
},
|
href: "/dashboard/rules",
|
||||||
],
|
icon: <SlidersHorizontal />,
|
||||||
},
|
children: getCategorySidebarChildren("rules"),
|
||||||
{
|
},
|
||||||
title: "Freight configuration",
|
],
|
||||||
mutedTitle: true,
|
},
|
||||||
items: [
|
];
|
||||||
{
|
|
||||||
label: "Configuration",
|
const hasPermission = (
|
||||||
href: "/dashboard/configuration",
|
user: ReturnType<typeof useAuth>["user"],
|
||||||
icon: <Boxes />,
|
key: string,
|
||||||
children: getCategorySidebarChildren("configuration"),
|
) => {
|
||||||
},
|
if (!user) return false;
|
||||||
{
|
if (user.permissions?.some((p) => p.key === key)) return true;
|
||||||
label: "Rules",
|
|
||||||
href: "/dashboard/rules",
|
return (user.employee ?? []).some((emp) =>
|
||||||
icon: <SlidersHorizontal />,
|
(emp.positions ?? []).some((pos) =>
|
||||||
children: getCategorySidebarChildren("rules"),
|
(pos.permissions ?? []).some((p) => p.key === key),
|
||||||
},
|
),
|
||||||
],
|
);
|
||||||
},
|
};
|
||||||
];
|
|
||||||
|
const DashboardShell = () => {
|
||||||
const hasPermission = (
|
const navigate = useNavigate();
|
||||||
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
|
const location = useLocation();
|
||||||
user: ReturnType<typeof useAuth>["user"],
|
const { user, logout } = useAuth();
|
||||||
category: RuleEngineNavCategory,
|
|
||||||
): SidebarItem[] =>
|
const demoItems: SidebarItem[] = [
|
||||||
getCategorySidebarChildren(category).filter((item) => {
|
...(hasPermission(user, "can:demo:user1")
|
||||||
const slug = item.href.split("/").pop() as RuleEngineResourceSlug;
|
? [
|
||||||
return canAccessRuleEngineResource(user, slug, "view");
|
{
|
||||||
});
|
label: "User1",
|
||||||
|
href: "/dashboard/user1",
|
||||||
const buildSidebarSections = (
|
icon: <Settings />,
|
||||||
user: ReturnType<typeof useAuth>["user"],
|
},
|
||||||
demoItems: SidebarItem[],
|
]
|
||||||
): SidebarSection[] => {
|
: []),
|
||||||
const configurationChildren = filterRuleEngineChildren(user, "configuration");
|
...(hasPermission(user, "can:demo:user2")
|
||||||
const rulesChildren = filterRuleEngineChildren(user, "rules");
|
? [
|
||||||
|
{
|
||||||
const mainItems: SidebarItem[] = [
|
label: "User2",
|
||||||
{
|
href: "/dashboard/user2",
|
||||||
label: "Overview",
|
icon: <Settings />,
|
||||||
href: "/dashboard/overview",
|
},
|
||||||
icon: <LayoutDashboard />,
|
]
|
||||||
},
|
: []),
|
||||||
...(canAccessBookings(user)
|
];
|
||||||
? [
|
|
||||||
{
|
const sidebarSections = buildSidebarSections(demoItems);
|
||||||
label: "Booking requests",
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
href: "/dashboard/booking-requests",
|
|
||||||
icon: <FileText />,
|
return (
|
||||||
},
|
<FreightDashboardLayout
|
||||||
]
|
sidebarSections={sidebarSections}
|
||||||
: []),
|
activeHref={location.pathname}
|
||||||
...demoItems,
|
onNavigate={navigate}
|
||||||
];
|
enableThemeToggle
|
||||||
|
userName={displayName}
|
||||||
const freightConfigItems: SidebarItem[] = [];
|
userEmail={user?.email}
|
||||||
if (configurationChildren.length) {
|
onLogout={logout}
|
||||||
freightConfigItems.push({
|
>
|
||||||
label: "Configuration",
|
<Outlet />
|
||||||
href: "/dashboard/configuration",
|
</FreightDashboardLayout>
|
||||||
icon: <Boxes />,
|
);
|
||||||
children: configurationChildren,
|
};
|
||||||
});
|
|
||||||
}
|
const App = () => {
|
||||||
if (rulesChildren.length) {
|
const { user, loading } = useAuth();
|
||||||
freightConfigItems.push({
|
|
||||||
label: "Rules",
|
if (loading) {
|
||||||
href: "/dashboard/rules",
|
return <LoadingScreen />;
|
||||||
icon: <SlidersHorizontal />,
|
}
|
||||||
children: rulesChildren,
|
|
||||||
});
|
if (!user) {
|
||||||
}
|
return (
|
||||||
|
<Routes>
|
||||||
const sections: SidebarSection[] = [
|
<Route path="/auth" element={<LoginPage />} />
|
||||||
{ title: "Main menu", mutedTitle: true, items: mainItems },
|
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||||
{
|
</Routes>
|
||||||
title: "Administration",
|
);
|
||||||
items: [
|
}
|
||||||
{
|
|
||||||
label: "User management",
|
return (
|
||||||
href: "/dashboard/user-management",
|
<Routes>
|
||||||
icon: <Network />,
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
children: [
|
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
{ label: "Users", href: "/dashboard/user-management/users" },
|
|
||||||
{ label: "Position Types", href: "/dashboard/user-management/position-types" },
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
{ label: "Permissions", href: "/dashboard/user-management/permissions" },
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
{ label: "Roles", href: "/dashboard/user-management/roles" },
|
|
||||||
],
|
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||||
},
|
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||||
{
|
<Route
|
||||||
label: "File settings",
|
path="booking-requests/:id/contract"
|
||||||
href: "/dashboard/file-settings",
|
element={<BookingContractPage />}
|
||||||
icon: <Paperclip />,
|
/>
|
||||||
},
|
{/* <Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||||
{
|
|
||||||
label: "Dropdown settings",
|
<Route path="trains" element={<TrainsPage />} /> */}
|
||||||
href: "/dashboard/dropdown-settings",
|
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||||
icon: <Settings />,
|
<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 />} />
|
||||||
if (freightConfigItems.length) {
|
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
||||||
sections.push({
|
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
||||||
title: "Freight configuration",
|
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||||
mutedTitle: true,
|
<Route path="user-management/roles" element={<RolesPage />} />
|
||||||
items: freightConfigItems,
|
|
||||||
});
|
<Route path="file-settings" element={<FileUploadSettingsPage />} />
|
||||||
}
|
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
|
||||||
|
|
||||||
return sections;
|
<Route
|
||||||
};
|
path="configuration"
|
||||||
|
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||||
const PermissionRoute = ({
|
/>
|
||||||
allow,
|
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||||
children,
|
|
||||||
}: {
|
<Route
|
||||||
allow: boolean;
|
path="rules"
|
||||||
children: ReactNode;
|
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
|
||||||
}) => (allow ? children : <Navigate to="/dashboard/overview" replace />);
|
/>
|
||||||
|
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
||||||
const DashboardShell = () => {
|
|
||||||
const navigate = useNavigate();
|
<Route
|
||||||
const location = useLocation();
|
path="rule-engine"
|
||||||
const { user, logout } = useAuth();
|
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||||
|
/>
|
||||||
const demoItems: SidebarItem[] = [
|
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
||||||
...(hasPermission(user, "can:demo:user1")
|
|
||||||
? [{ label: "User1", href: "/dashboard/user1", icon: <Settings /> }]
|
<Route path="user1" element={<DemoUser1Page />} />
|
||||||
: []),
|
<Route path="user2" element={<DemoUser2Page />} />
|
||||||
...(hasPermission(user, "can:demo:user2")
|
|
||||||
? [{ label: "User2", href: "/dashboard/user2", icon: <Settings /> }]
|
<Route
|
||||||
: []),
|
path="org-structure"
|
||||||
];
|
element={<Navigate to="/dashboard/user-management" replace />}
|
||||||
|
/>
|
||||||
const sidebarSections = buildSidebarSections(user, demoItems);
|
<Route
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
path="org-structure/*"
|
||||||
|
element={<Navigate to="/dashboard/user-management" replace />}
|
||||||
return (
|
/>
|
||||||
<FreightDashboardLayout
|
</Route>
|
||||||
sidebarSections={sidebarSections}
|
|
||||||
activeHref={location.pathname}
|
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
onNavigate={navigate}
|
</Routes>
|
||||||
enableThemeToggle
|
);
|
||||||
userName={displayName}
|
};
|
||||||
userEmail={user?.email}
|
|
||||||
onLogout={logout}
|
export default App;
|
||||||
>
|
|
||||||
<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;
|
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ShieldCheck } from "lucide-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 { 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 { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
||||||
import { Badge } from "@edr/ui-common";
|
import { Badge, Button } from "@edr/ui-common";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||||
|
|
||||||
interface ApprovalStepsCardProps {
|
interface ApprovalStepsCardProps {
|
||||||
booking: BookingDetail;
|
booking: BookingDetail;
|
||||||
|
mutations: Mutations;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read-only approval chain visualization; actions live in BookingActionsToolbar. */
|
/** Approval chain with inline approve on the current pending step. */
|
||||||
export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
|
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const steps = useMemo(
|
const steps = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...(booking.approvalSteps ?? [])].sort(
|
[...(booking.approvalSteps ?? [])].sort(
|
||||||
@@ -24,57 +40,110 @@ export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
|
|||||||
|
|
||||||
const nextPending = getNextPendingApprovalStep(steps);
|
const nextPending = getNextPendingApprovalStep(steps);
|
||||||
const summary = formatApprovalProgress(booking.status, 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 (
|
return (
|
||||||
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
|
<>
|
||||||
<div className={bookingSurface.sectionHeader}>
|
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
|
||||||
<div className={bookingSurface.sectionIcon}>
|
<div className={bookingSurface.sectionHeader}>
|
||||||
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
<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>
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-foreground">
|
<div className="px-5 py-5">
|
||||||
Approval chain
|
{steps.length === 0 ? (
|
||||||
</h2>
|
<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">
|
||||||
<p className="text-xs text-muted-foreground">
|
Use{" "}
|
||||||
{summary.detail ||
|
<strong className="font-semibold text-foreground">
|
||||||
(nextPending
|
Accept for approval
|
||||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
</strong>{" "}
|
||||||
: steps.length
|
in staff actions to instantiate steps.
|
||||||
? "All steps complete"
|
</p>
|
||||||
: "Accept submission to begin")}
|
) : (
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-5">
|
<BookingConfirmDialog
|
||||||
{steps.length === 0 ? (
|
open={confirmOpen}
|
||||||
<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">
|
onOpenChange={(open) => {
|
||||||
Use <strong className="font-semibold text-foreground">Accept for approval</strong>{" "}
|
if (!open) closeApprove();
|
||||||
in staff actions to instantiate steps.
|
else setConfirmOpen(true);
|
||||||
</p>
|
}}
|
||||||
) : (
|
action={pendingAction}
|
||||||
<ul className="space-y-2">
|
reference={booking.reference}
|
||||||
{steps.map((step) => (
|
inputValue=""
|
||||||
<StepRow
|
onInputChange={() => {}}
|
||||||
key={step.id}
|
onConfirm={runApprove}
|
||||||
step={step}
|
isPending={mutations.approveStep.isPending}
|
||||||
isNext={nextPending?.id === step.id}
|
/>
|
||||||
/>
|
</>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepRow({
|
function StepRow({
|
||||||
step,
|
step,
|
||||||
|
steps,
|
||||||
|
user,
|
||||||
isNext,
|
isNext,
|
||||||
|
isPending,
|
||||||
|
onApprove,
|
||||||
}: {
|
}: {
|
||||||
step: BookingApprovalStep;
|
step: BookingApprovalStep;
|
||||||
|
steps: BookingApprovalStep[];
|
||||||
|
user: ReturnType<typeof useAuth>["user"];
|
||||||
isNext: boolean;
|
isNext: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
onApprove: (step: BookingApprovalStep) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const canApprove = canActOnApprovalStep(user, step, steps);
|
||||||
const statusStyles =
|
const statusStyles =
|
||||||
step.status === "APPROVED"
|
step.status === "APPROVED"
|
||||||
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
||||||
@@ -88,9 +157,7 @@ function StepRow({
|
|||||||
<li
|
<li
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
|
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
|
||||||
isNext
|
isNext ? bookingGlass.activeTab : "border-border/50 bg-card/60",
|
||||||
? bookingGlass.activeTab
|
|
||||||
: "border-border/50 bg-card/60",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
@@ -115,12 +182,26 @@ function StepRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
variant="outline"
|
{canApprove && (
|
||||||
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
|
<Button
|
||||||
>
|
type="button"
|
||||||
{step.status}
|
size="sm"
|
||||||
</Badge>
|
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>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useWagons, useAssignWagonToTrain } from '@/hooks/useWagons';
|
import { useWagons, useAssignWagonToTrain } from '@/hooks/useWagons';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
|
||||||
|
|
||||||
export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -16,7 +17,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
|||||||
const assign = useAssignWagonToTrain();
|
const assign = useAssignWagonToTrain();
|
||||||
const { toast } = useToast();
|
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 () => {
|
const handleAssign = async () => {
|
||||||
if (!wagonId) return;
|
if (!wagonId) return;
|
||||||
@@ -36,7 +37,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
|||||||
<Select value={wagonId} onValueChange={setWagonId}>
|
<Select value={wagonId} onValueChange={setWagonId}>
|
||||||
<SelectTrigger><SelectValue placeholder="Select wagon" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Select wagon" /></SelectTrigger>
|
||||||
<SelectContent>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/us
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Trash2, GripVertical } from 'lucide-react';
|
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 }) {
|
export function WagonsTable({ trainId }: { trainId: string }) {
|
||||||
const { data: wagons, refetch } = useWagonsByTrain(trainId);
|
const { data: wagons, refetch } = useWagonsByTrain(trainId);
|
||||||
@@ -14,50 +14,51 @@ export function WagonsTable({ trainId }: { trainId: string }) {
|
|||||||
const items = Array.from(wagons || []);
|
const items = Array.from(wagons || []);
|
||||||
const [removed] = items.splice(result.source.index, 1);
|
const [removed] = items.splice(result.source.index, 1);
|
||||||
items.splice(result.destination.index, 0, removed);
|
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>;
|
if (!wagons?.length) return <div className="text-muted-foreground">No wagons assigned.</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<div></div>
|
||||||
<Droppable droppableId="wagons">
|
// <DragDropContext onDragEnd={onDragEnd}>
|
||||||
{(provided) => (
|
// <Droppable droppableId="wagons">
|
||||||
<Table {...provided.droppableProps} ref={provided.innerRef}>
|
// {(provided) => (
|
||||||
<TableHeader>
|
// <Table {...provided.droppableProps} ref={provided.innerRef}>
|
||||||
<TableRow>
|
// <TableHeader>
|
||||||
<TableHead className="w-10"></TableHead>
|
// <TableRow>
|
||||||
<TableHead>Number</TableHead>
|
// <TableHead className="w-10"></TableHead>
|
||||||
<TableHead>Type</TableHead>
|
// <TableHead>Number</TableHead>
|
||||||
<TableHead>Sequence</TableHead>
|
// <TableHead>Type</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
// <TableHead>Sequence</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
// <TableHead>Status</TableHead>
|
||||||
</TableRow>
|
// <TableHead>Actions</TableHead>
|
||||||
</TableHeader>
|
// </TableRow>
|
||||||
<TableBody>
|
// </TableHeader>
|
||||||
{wagons.map((wagon, idx) => (
|
// <TableBody>
|
||||||
<Draggable key={wagon.id} draggableId={wagon.id} index={idx}>
|
// {wagons.map((wagon, idx) => (
|
||||||
{(provided) => (
|
// <Draggable key={wagon.id} draggableId={wagon.id} index={idx}>
|
||||||
<TableRow ref={provided.innerRef} {...provided.draggableProps}>
|
// {(provided) => (
|
||||||
<TableCell {...provided.dragHandleProps}><GripVertical className="h-4 w-4 cursor-grab" /></TableCell>
|
// <TableRow ref={provided.innerRef} {...provided.draggableProps}>
|
||||||
<TableCell>{wagon.wagonNumber}</TableCell>
|
// <TableCell {...provided.dragHandleProps}><GripVertical className="h-4 w-4 cursor-grab" /></TableCell>
|
||||||
<TableCell>{wagon.wagonTypeId}</TableCell>
|
// <TableCell>{wagon.wagonNumber}</TableCell>
|
||||||
<TableCell>{wagon.sequenceNumber}</TableCell>
|
// <TableCell>{wagon.wagonTypeId}</TableCell>
|
||||||
<TableCell>{wagon.status}</TableCell>
|
// <TableCell>{wagon.sequenceNumber}</TableCell>
|
||||||
<TableCell>
|
// <TableCell>{wagon.status}</TableCell>
|
||||||
<Button variant="ghost" size="icon" onClick={() => unassign.mutateAsync(wagon.id).then(() => refetch())}>
|
// <TableCell>
|
||||||
<Trash2 className="h-4 w-4" />
|
// <Button variant="ghost" size="icon" onClick={() => unassign.mutateAsync(wagon.id).then(() => refetch())}>
|
||||||
</Button>
|
// <Trash2 className="h-4 w-4" />
|
||||||
</TableCell>
|
// </Button>
|
||||||
</TableRow>
|
// </TableCell>
|
||||||
)}
|
// </TableRow>
|
||||||
</Draggable>
|
// )}
|
||||||
))}
|
// </Draggable>
|
||||||
{provided.placeholder}
|
// ))}
|
||||||
</TableBody>
|
// {provided.placeholder}
|
||||||
</Table>
|
// </TableBody>
|
||||||
)}
|
// </Table>
|
||||||
</Droppable>
|
// )}
|
||||||
</DragDropContext>
|
// </Droppable>
|
||||||
|
// </DragDropContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import type { AuthUser } from "@/auth/types";
|
import type { AuthUser } from "@/auth/types";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import {
|
||||||
|
FREIGHT_PERMS,
|
||||||
|
hasPermission,
|
||||||
|
isFreightApprovalAdmin,
|
||||||
|
} from "@/lib/permissions";
|
||||||
import type {
|
import type {
|
||||||
BookingApprovalStep,
|
BookingApprovalStep,
|
||||||
BookingDetail,
|
BookingDetail,
|
||||||
@@ -71,18 +75,7 @@ function approvalActions(
|
|||||||
const next = getNextPendingApprovalStep(steps);
|
const next = getNextPendingApprovalStep(steps);
|
||||||
if (!next) return [];
|
if (!next) return [];
|
||||||
return [
|
return [
|
||||||
{
|
buildApproveActionForStep(next),
|
||||||
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,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "rejectApproval",
|
id: "rejectApproval",
|
||||||
label: "Reject approval",
|
label: "Reject approval",
|
||||||
@@ -219,6 +212,37 @@ const approvePermissionForRole = (role: string): string | undefined => {
|
|||||||
return 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(
|
function filterActionsByUser(
|
||||||
actions: BookingActionDef[],
|
actions: BookingActionDef[],
|
||||||
user: AuthUser | null | undefined,
|
user: AuthUser | null | undefined,
|
||||||
@@ -228,8 +252,7 @@ function filterActionsByUser(
|
|||||||
const next = getNextPendingApprovalStep(approvalSteps);
|
const next = getNextPendingApprovalStep(approvalSteps);
|
||||||
return actions.filter((action) => {
|
return actions.filter((action) => {
|
||||||
if (action.id === "approve" && next) {
|
if (action.id === "approve" && next) {
|
||||||
const perm = approvePermissionForRole(next.requiredRole);
|
return canActOnApprovalStep(user, next, approvalSteps);
|
||||||
return perm ? hasPermission(user, perm) : false;
|
|
||||||
}
|
}
|
||||||
const perm = ACTION_PERMISSION[action.id];
|
const perm = ACTION_PERMISSION[action.id];
|
||||||
return perm ? hasPermission(user, perm) : true;
|
return perm ? hasPermission(user, perm) : true;
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
|
|||||||
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
|
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(
|
export function hasPermission(
|
||||||
user: AuthUser | null | undefined,
|
user: AuthUser | null | undefined,
|
||||||
key: string,
|
key: string,
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export default function BookingRequestDetailPage() {
|
|||||||
)}
|
)}
|
||||||
{(booking.status === "PENDING_APPROVAL" ||
|
{(booking.status === "PENDING_APPROVAL" ||
|
||||||
booking.status === "APPROVED_PENDING_SIGNATURE") && (
|
booking.status === "APPROVED_PENDING_SIGNATURE") && (
|
||||||
<ApprovalStepsCard booking={booking} />
|
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCargoes } from '@/hooks/useCargoes';
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { LoadCargoDialog } from '@/components/cargoes/LoadCargoDialog';
|
import { LoadCargoDialog } from '@/components/cargoes/LoadCargoDialog';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
|
||||||
|
|
||||||
export default function CargoesPage() {
|
export default function CargoesPage() {
|
||||||
const { data: cargoes, refetch, isLoading } = useCargoes();
|
const { data: cargoes, refetch, isLoading } = useCargoes();
|
||||||
@@ -14,7 +15,7 @@ export default function CargoesPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader><TableRow><TableHead>Reference</TableHead><TableHead>Description</TableHead><TableHead>Quantity</TableHead><TableHead>Weight</TableHead><TableHead>Status</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
|
<TableHeader><TableRow><TableHead>Reference</TableHead><TableHead>Description</TableHead><TableHead>Quantity</TableHead><TableHead>Weight</TableHead><TableHead>Status</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{cargoes?.map(c => (
|
{cargoes?.map((c:any) => (
|
||||||
<TableRow key={c.id}>
|
<TableRow key={c.id}>
|
||||||
<TableCell>{c.cargoReference}</TableCell>
|
<TableCell>{c.cargoReference}</TableCell>
|
||||||
<TableCell>{c.description || '-'}</TableCell>
|
<TableCell>{c.description || '-'}</TableCell>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useContainers } from '@/hooks/useContainers';
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
|
||||||
|
|
||||||
export default function ContainersPage() {
|
export default function ContainersPage() {
|
||||||
const { data: containers, isLoading } = useContainers();
|
const { data: containers, isLoading } = useContainers();
|
||||||
@@ -13,7 +14,7 @@ export default function ContainersPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Wagon</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Wagon</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{containers?.map(c => (
|
{containers?.map((c:any) => (
|
||||||
<TableRow key={c.id}>
|
<TableRow key={c.id}>
|
||||||
<TableCell>{c.containerNumber}</TableCell>
|
<TableCell>{c.containerNumber}</TableCell>
|
||||||
<TableCell>{c.containerTypeId}</TableCell>
|
<TableCell>{c.containerTypeId}</TableCell>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useTrain } from '@/hooks/useTrains';
|
import { useTrain } from '@/hooks/useTrains';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
// import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { AssignWagonDialog } from '@/components/AssignWagonDialog';
|
// import { AssignWagonDialog } from '@/components/AssignWagonDialog';
|
||||||
import { WagonsTable } from '@/components/WagonsTable';
|
// 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() {
|
export default function TrainDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|||||||
@@ -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 { useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { isAxiosError } from 'axios';
|
import { isAxiosError } from 'axios';
|
||||||
@@ -794,45 +755,44 @@ const TrainsPage = () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
>>>>>>> 523d7e58422f1bde8024c2dd237092a7cf6aa190
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TrainsPage() {
|
// export default function TrainsPage() {
|
||||||
const { data: trains, isLoading } = useTrains();
|
// const { data: trains, isLoading } = useTrains();
|
||||||
const deleteTrain = useDeleteTrain();
|
// const deleteTrain = useDeleteTrain();
|
||||||
const [open, setOpen] = useState(false);
|
// 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 (
|
// return (
|
||||||
<Card>
|
// <Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
// <CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>Trains</CardTitle>
|
// <CardTitle>Trains</CardTitle>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
// <Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />New Train</Button></DialogTrigger>
|
// <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>
|
// <DialogContent><DialogHeader><DialogTitle>Create Train</DialogTitle></DialogHeader><CreateTrainForm onSuccess={() => setOpen(false)} /></DialogContent>
|
||||||
</Dialog>
|
// </Dialog>
|
||||||
</CardHeader>
|
// </CardHeader>
|
||||||
<CardContent>
|
// <CardContent>
|
||||||
<Table>
|
// <Table>
|
||||||
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Name</TableHead><TableHead>Status</TableHead><TableHead>Capacity</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
|
// <TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Name</TableHead><TableHead>Status</TableHead><TableHead>Capacity</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
|
||||||
<TableBody>
|
// <TableBody>
|
||||||
{trains?.map(train => (
|
// {trains?.map(train => (
|
||||||
<TableRow key={train.id}>
|
// <TableRow key={train.id}>
|
||||||
<TableCell>{train.trainNumber || train.code}</TableCell>
|
// <TableCell>{train.trainNumber || train.code}</TableCell>
|
||||||
<TableCell>{train.trainName || '-'}</TableCell>
|
// <TableCell>{train.trainName || '-'}</TableCell>
|
||||||
<TableCell><Badge variant="outline">{train.status}</Badge></TableCell>
|
// <TableCell><Badge variant="outline">{train.status}</Badge></TableCell>
|
||||||
<TableCell>{train.capacityTons} t</TableCell>
|
// <TableCell>{train.capacityTons} t</TableCell>
|
||||||
<TableCell className="flex space-x-2">
|
// <TableCell className="flex space-x-2">
|
||||||
<Link to={`/trains/${train.id}`}><Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button></Link>
|
// <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>
|
// <Button variant="ghost" size="icon" onClick={() => deleteTrain.mutate(train.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||||
</TableCell>
|
// </TableCell>
|
||||||
</TableRow>
|
// </TableRow>
|
||||||
))}
|
// ))}
|
||||||
</TableBody>
|
// </TableBody>
|
||||||
</Table>
|
// </Table>
|
||||||
</CardContent>
|
// </CardContent>
|
||||||
</Card>
|
// </Card>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useWagons } from '@/hooks/useWagons';
|
import { useWagons } from '@/hooks/useWagons';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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() {
|
export default function WagonsPage() {
|
||||||
const { data: wagons, isLoading } = useWagons();
|
const { data: wagons, isLoading } = useWagons();
|
||||||
@@ -13,7 +15,7 @@ export default function WagonsPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Train</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Train</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{wagons?.map(w => (
|
{wagons?.map((w:any) => (
|
||||||
<TableRow key={w.id}>
|
<TableRow key={w.id}>
|
||||||
<TableCell>{w.wagonNumber}</TableCell>
|
<TableCell>{w.wagonNumber}</TableCell>
|
||||||
<TableCell>{w.wagonTypeId}</TableCell>
|
<TableCell>{w.wagonTypeId}</TableCell>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { apiClient } from '@/lib/axios';
|
// import { apiClient } from '@/lib/axios';
|
||||||
|
import { api as apiClient } from "../auth/http";
|
||||||
|
|
||||||
|
|
||||||
export interface Cargo {
|
export interface Cargo {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { apiClient } from '@/lib/axios';
|
// import { apiClient } from '@/lib/axios';
|
||||||
|
import { api as apiClient } from "../auth/http";
|
||||||
|
|
||||||
|
|
||||||
export interface Container {
|
export interface Container {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { apiClient } from '@/lib/axios';
|
// import { apiClient } from '@/lib/axios';
|
||||||
|
import { api as apiClient } from "../auth/http";
|
||||||
|
|
||||||
|
|
||||||
export interface Train {
|
export interface Train {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiClient } from '@/lib/axios';
|
import { api as apiClient } from "../auth/http";
|
||||||
|
|
||||||
export interface Wagon {
|
export interface Wagon {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
2086
pnpm-lock.yaml
generated
2086
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user