Merge pull request #190 from Tria-plc/freight_feature/priority

Freight feature/priority
This commit is contained in:
marshal
2026-06-17 07:49:05 +03:00
committed by GitHub
54 changed files with 1601 additions and 109 deletions

View File

@@ -15,6 +15,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
},
"dependencies": {

View File

@@ -48,6 +48,7 @@ import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module';
@@ -121,6 +122,7 @@ import { OverviewModule } from './modules/overview/overview.module';
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -133,6 +135,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
async onApplicationBootstrap() {
@@ -144,5 +147,8 @@ export class AppModule implements OnApplicationBootstrap {
await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
await this.demoFreightDataSeeder.run();
}
}

View File

@@ -21,3 +21,10 @@ export const TrainSchedulingView = () =>
export const TrainSchedulingManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);

View File

@@ -10,12 +10,14 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BackofficeService } from "./backoffice.service";
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
@ApiTags("backoffice")
@Controller("backoffice")
@FreightAdmin()
export class BackofficeController {
constructor(private readonly backofficeService: BackofficeService) {}

View File

@@ -1,10 +1,12 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BillingService } from "./billing.service";
@ApiTags("billing")
@Controller("billing")
@FreightAdmin()
export class BillingController {
constructor(private readonly billingService: BillingService) {}

View File

@@ -10,6 +10,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateCargoDto } from './dto/create-cargo.dto';
import { UpdateCargoDto } from './dto/update-cargo.dto';
import { LoadCargoDto } from './dto/load-cargo.dto';
@@ -18,10 +19,12 @@ import { CargoesService } from './cargoes.service';
@ApiTags('cargoes')
@Controller('cargoes')
@FleetView()
export class CargoesController {
constructor(private readonly cargoesService: CargoesService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new cargo' })
create(@Body() dto: CreateCargoDto) {
return this.cargoesService.create(dto);
@@ -40,30 +43,35 @@ export class CargoesController {
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a cargo' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
return this.cargoesService.update(id, dto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a cargo' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.remove(id);
}
@Post(':id/load')
@FleetManage()
@ApiOperation({ summary: 'Load cargo into a container' })
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
return this.cargoesService.loadCargo(id, dto);
}
@Post(':id/unload')
@FleetManage()
@ApiOperation({ summary: 'Unload cargo from container' })
unload(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.unloadCargo(id);
}
@Post(':id/deliver')
@FleetManage()
@ApiOperation({ summary: 'Mark cargo as delivered' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
return this.cargoesService.deliverCargo(id, dto);

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FreightAdmin } from '../../common/booking-guards';
import { FilesService } from '../files/files.service';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto';
@@ -82,6 +83,7 @@ export class CompaniesController {
}
@Post()
@FreightAdmin()
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
const company = await this.companiesService.createCompany(dto);
@@ -119,6 +121,7 @@ export class CompaniesController {
}
@Patch(':id')
@FreightAdmin()
@ApiOperation({ summary: 'Update a company' })
async update(
@Param('id', ParseUUIDPipe) id: string,
@@ -129,6 +132,7 @@ export class CompaniesController {
}
@Delete(':id')
@FreightAdmin()
@ApiOperation({ summary: 'Soft-delete a company' })
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
@@ -147,6 +151,7 @@ export class CompaniesController {
}
@Post(':companyId/profiles')
@FreightAdmin()
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
async createProfile(
@Param('companyId', ParseUUIDPipe) companyId: string,
@@ -175,6 +180,7 @@ export class CompaniesController {
}
@Post('ff-clients')
@FreightAdmin()
@ApiOperation({ summary: 'Link a forwarder to a client company' })
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
const client = await this.companiesService.createFFClient(dto);
@@ -191,6 +197,7 @@ export class CompaniesController {
}
@Delete('ff-clients/:id')
@FreightAdmin()
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
@HttpCode(HttpStatus.NO_CONTENT)
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {

View File

@@ -9,16 +9,19 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FleetManage, FleetView } from "../../common/booking-guards";
import { ConsignmentsService } from "./consignments.service";
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
@ApiTags("consignments")
@Controller("consignments")
@FleetView()
export class ConsignmentsController {
constructor(private readonly consignmentsService: ConsignmentsService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: "Create a new consignment" })
create(@Body() dto: CreateConsignmentDto) {
return this.consignmentsService.create(dto);

View File

@@ -10,6 +10,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
@@ -17,10 +18,12 @@ import { ContainersService } from './containers.service';
@ApiTags('containers')
@Controller('containers')
@FleetView()
export class ContainersController {
constructor(private readonly containersService: ContainersService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new container' })
create(@Body() dto: CreateContainerDto) {
return this.containersService.create(dto);
@@ -39,24 +42,28 @@ export class ContainersController {
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a container' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
return this.containersService.update(id, dto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a container' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.remove(id);
}
@Post(':id/assign-wagon')
@FleetManage()
@ApiOperation({ summary: 'Assign container to a wagon' })
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
return this.containersService.assignToWagon(id, dto);
}
@Post(':id/unassign-wagon')
@FleetManage()
@ApiOperation({ summary: 'Unassign container from wagon' })
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.unassignFromWagon(id);

View File

@@ -16,12 +16,14 @@ import {
import { ApiOperation } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CustomersService } from "./customers.service";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@Controller("customers")
@FreightAdmin()
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}

View File

@@ -13,6 +13,7 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
@@ -24,6 +25,9 @@ import { DropdownSettingsService } from "./dropdown-settings.service";
export class DropdownSettingsController {
constructor(private readonly service: DropdownSettingsService) {}
// Reads stay open: the customer portal fetches these to render dynamic
// dropdowns (by-code). Only writes are admin-guarded.
@Get()
@ApiOperation({ summary: "List all dropdown settings" })
list() {
@@ -43,12 +47,14 @@ export class DropdownSettingsController {
}
@Post()
@FreightAdmin()
@ApiOperation({ summary: "Create a new dropdown setting" })
create(@Body() dto: CreateDropdownSettingDto) {
return this.service.create(dto);
}
@Patch(":id")
@FreightAdmin()
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
update(
@Param("id", ParseUUIDPipe) id: string,
@@ -58,6 +64,7 @@ export class DropdownSettingsController {
}
@Delete(":id")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -67,6 +74,7 @@ export class DropdownSettingsController {
/* ------------------------- option routes ------------------------- */
@Put(":id/options")
@FreightAdmin()
@ApiOperation({ summary: "Replace the full option list for a setting" })
replaceOptions(
@Param("id", ParseUUIDPipe) id: string,
@@ -76,6 +84,7 @@ export class DropdownSettingsController {
}
@Post(":id/options")
@FreightAdmin()
@ApiOperation({ summary: "Append a single option to a setting" })
addOption(
@Param("id", ParseUUIDPipe) id: string,
@@ -85,6 +94,7 @@ export class DropdownSettingsController {
}
@Patch("options/:optionId")
@FreightAdmin()
@ApiOperation({ summary: "Update a single option" })
updateOption(
@Param("optionId", ParseUUIDPipe) optionId: string,
@@ -94,6 +104,7 @@ export class DropdownSettingsController {
}
@Delete("options/:optionId")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a single option" })
@HttpCode(HttpStatus.NO_CONTENT)
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {

View File

@@ -13,6 +13,7 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
@@ -24,6 +25,9 @@ import { FileUploadSettingsService } from "./file-upload-settings.service";
export class FileUploadSettingsController {
constructor(private readonly service: FileUploadSettingsService) {}
// Reads stay open: the customer portal fetches these to render dynamic
// upload forms (by-code / by-entity). Only writes are admin-guarded.
@Get()
@ApiOperation({ summary: "List all file upload settings" })
list() {
@@ -49,12 +53,14 @@ export class FileUploadSettingsController {
}
@Post()
@FreightAdmin()
@ApiOperation({ summary: "Create a new file upload setting" })
create(@Body() dto: CreateFileUploadSettingDto) {
return this.service.create(dto);
}
@Patch(":id")
@FreightAdmin()
@ApiOperation({ summary: "Update a file upload setting's metadata" })
update(
@Param("id", ParseUUIDPipe) id: string,
@@ -64,6 +70,7 @@ export class FileUploadSettingsController {
}
@Delete(":id")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a file upload setting" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -73,6 +80,7 @@ export class FileUploadSettingsController {
/* ------------------------- field routes ------------------------- */
@Put(":id/fields")
@FreightAdmin()
@ApiOperation({ summary: "Replace the full field list for a setting" })
replaceFields(
@Param("id", ParseUUIDPipe) id: string,
@@ -82,6 +90,7 @@ export class FileUploadSettingsController {
}
@Post(":id/fields")
@FreightAdmin()
@ApiOperation({ summary: "Append a single field to a setting" })
addField(
@Param("id", ParseUUIDPipe) id: string,
@@ -91,6 +100,7 @@ export class FileUploadSettingsController {
}
@Patch("fields/:fieldId")
@FreightAdmin()
@ApiOperation({ summary: "Update a single field" })
updateField(
@Param("fieldId", ParseUUIDPipe) fieldId: string,
@@ -100,6 +110,7 @@ export class FileUploadSettingsController {
}
@Delete("fields/:fieldId")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a single field" })
@HttpCode(HttpStatus.NO_CONTENT)
removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) {

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
@@ -9,6 +10,7 @@ import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives')
@ApiBearerAuth()
@Controller('locomotives')
@FleetView()
export class LocomotivesController {
constructor(private readonly locomotivesService: LocomotivesService) {}
@@ -25,18 +27,21 @@ export class LocomotivesController {
}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a locomotive' })
create(@Body() dto: CreateLocomotiveDto) {
return this.locomotivesService.create(dto);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a locomotive' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
return this.locomotivesService.update(id, dto);
}
@Post(':id/decommission')
@FleetManage()
@ApiOperation({ summary: 'Decommission a locomotive' })
decommission(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.decommission(id);

View File

@@ -17,6 +17,7 @@ import {
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { BookingView, FreightAdmin } from "../../common/booking-guards";
import { PaymentService } from "./payment.service";
import {
InitiatePaymentDto,
@@ -32,8 +33,16 @@ import {
export class PaymentController {
constructor(private readonly paymentService: PaymentService) { }
@Get("summary")
@BookingView()
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })
getSummary() {
return this.paymentService.getSummary();
}
@Get("all")
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
@BookingView()
@ApiOperation({ summary: "Get all payments with filters (view-only, any staff)" })
@ApiQuery({ name: "search", required: false })
@ApiQuery({ name: "status", required: false })
@ApiQuery({ name: "method", required: false })
@@ -73,6 +82,7 @@ export class PaymentController {
}
@Post("refund")
@FreightAdmin()
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
refund(@Body() dto: RefundDto) {
return this.paymentService.refund(dto);

View File

@@ -105,6 +105,40 @@ export class PaymentService {
};
}
/** Aggregate counts across ALL payments for the dashboard summary cards. */
async getSummary() {
const rows = await this.paymentRepo
.createQueryBuilder("payment")
.select("payment.status", "status")
.addSelect("COUNT(*)::int", "count")
.groupBy("payment.status")
.getRawMany<{ status: string; count: number }>();
const byStatus: Record<string, number> = {};
let total = 0;
for (const row of rows) {
byStatus[row.status] = row.count;
total += row.count;
}
// Sum of successfully collected amounts.
const paidAgg = await this.paymentRepo
.createQueryBuilder("payment")
.select("COALESCE(SUM(payment.amount), 0)", "sum")
.where("payment.status = :status", { status: "success" })
.getRawOne<{ sum: string }>();
return {
total,
success: byStatus["success"] ?? 0,
processing:
(byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0),
failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0),
refunded: byStatus["refunded"] ?? 0,
paidAmount: Number(paidAgg?.sum ?? 0),
};
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
const booking = await this.datasource
.getRepository(Booking)

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
@@ -9,6 +10,7 @@ import { RoutesService } from './routes.service';
@ApiTags('routes')
@ApiBearerAuth()
@Controller('routes')
@FleetView()
export class RoutesController {
constructor(private readonly routesService: RoutesService) {}
@@ -25,18 +27,21 @@ export class RoutesController {
}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create route' })
create(@Body() dto: CreateRouteDto) {
return this.routesService.create(dto);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update route' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
return this.routesService.update(id, dto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Deactivate route' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.routesService.deactivate(id);

View File

@@ -1,7 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Readable } from 'stream';
import { DataSource } from 'typeorm';
import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { MinioService } from '../minio/minio.service';
import { SignaturesRepository } from './signatures.repository';
import { SavedSignature } from './entities/saved-signature.entity';
@@ -19,6 +21,7 @@ export class SignaturesService {
private readonly signaturesRepository: SignaturesRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly dataSource: DataSource,
) {}
/** Saved signature for a user, with the image inlined as a data URL (or null). */
@@ -47,18 +50,32 @@ export class SignaturesService {
path: '',
};
const fileRecord = await this.filesService.upsertByCode({
// Capture the previously referenced file so we can remove it only AFTER the
// saved_signatures row is repointed — deleting it first would violate the
// FK constraint (saved_signatures.signature_file_id -> files.id).
const existing = await this.signaturesRepository.findByUserId(input.userId);
const previousFileId = existing?.signatureFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'signature',
file,
});
return this.signaturesRepository.upsert({
const saved = await this.signaturesRepository.upsert({
userId: input.userId,
signerDisplayName: input.signerDisplayName,
signatureFileId: fileRecord.id,
});
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource
.getRepository(FileRecord)
.delete({ id: previousFileId });
}
return saved;
}
private async inlineImageUrl(

View File

@@ -96,7 +96,8 @@ export class TrainSchedulingController {
}
@Get("bookable-schedules")
// @TrainSchedulingView()
// No staff guard: customers hit this while creating a booking to find OPEN
// same-route schedules. Do not attach train_scheduling permissions here.
@ApiOperation({
summary: "OPEN same-route schedules a new booking can target",
})

View File

@@ -11,16 +11,19 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FleetManage, FleetView } from "../../common/booking-guards";
import { CreateTrainDto } from "./dto/create-train.dto";
import { UpdateTrainDto } from "./dto/update-train.dto";
import { TrainsService } from "./trains.service";
@ApiTags("trains")
@Controller("trains")
@FleetView()
export class TrainsController {
constructor(private readonly trainsService: TrainsService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: "Register a new train" })
create(@Body() dto: CreateTrainDto) {
return this.trainsService.create(dto);
@@ -39,12 +42,14 @@ export class TrainsController {
}
@Patch(":id")
@FleetManage()
@ApiOperation({ summary: "Update a train" })
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
return this.trainsService.update(id, dto);
}
@Delete(":id")
@FleetManage()
@ApiOperation({ summary: "Delete a train" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.trainsService.remove(id);

View File

@@ -10,6 +10,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
@@ -19,10 +20,12 @@ import { WagonsService } from './wagons.service';
@ApiTags('wagons')
@Controller('wagons')
@FleetView()
export class WagonsController {
constructor(private readonly wagonsService: WagonsService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new wagon' })
create(@Body() dto: CreateWagonDto) {
return this.wagonsService.create(dto);
@@ -41,24 +44,28 @@ export class WagonsController {
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a wagon' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
return this.wagonsService.update(id, dto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a wagon' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.remove(id);
}
@Post(':id/assign-train')
@FleetManage()
@ApiOperation({ summary: 'Assign wagon to a train' })
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
return this.wagonsService.assignToTrain(id, dto);
}
@Post(':id/unassign-train')
@FleetManage()
@ApiOperation({ summary: 'Unassign wagon from train' })
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.unassignFromTrain(id);
@@ -67,10 +74,12 @@ export class WagonsController {
// Separate controller for trainspecific reorder (registered in module)
@Controller('trains/:trainId/reorder-wagons')
@FleetView()
export class TrainWagonsReorderController {
constructor(private readonly wagonsService: WagonsService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Reorder wagons of a train' })
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
return this.wagonsService.reorderWagons(trainId, dto);

View File

@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') });
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { DemoFreightDataSeeder } from '../seed/demo-freight-data.seeder';
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
try {
const seeder = app.get(DemoFreightDataSeeder);
await seeder.run();
console.log('Freight demo data seeded (wagons, approval rules, staff users).');
} finally {
await app.close();
}
}
main().catch((err) => {
console.error('Freight demo seed failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { hashPassword } from '@tria-plc/api-common/utils/argon';
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
import {
Employee,
Organization,
Role,
User,
UserCredential,
UserRole,
} from '@tria-plc/iamapi-common';
import { DataSource, EntityManager } from 'typeorm';
import { Wagon } from '../modules/wagons/entities/wagon.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { ApprovalRule } from '../modules/rule-engine/entities/approval-rule.entity';
import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rules.defaults';
const EDR_ORG_KEY = 'edr_freight';
const MIN_WAGONS_PER_TYPE = 100;
/** The four demo staff users, each mapped to a seeded freight role. */
const DEMO_STAFF_USERS = [
{ email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' },
{ email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' },
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
] as const;
/**
* One-shot demo data: at least 100 wagons per wagon type, the default approval
* chains, and four staff users with distinct permissions. Every block guards on
* an "is it already populated?" check, so this is safe to run on every boot and
* does nothing once the data exists.
*/
@Injectable()
export class DemoFreightDataSeeder {
private readonly logger = new Logger(DemoFreightDataSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
await this.dataSource.transaction(async (manager) => {
await this.seedWagons(manager);
await this.seedApprovalRules(manager);
await this.seedStaffUsers(manager);
});
}
/** Ensure every wagon type has at least MIN_WAGONS_PER_TYPE wagons. */
private async seedWagons(manager: EntityManager) {
const wagonTypeRepo = manager.getRepository(WagonType);
const wagonRepo = manager.getRepository(Wagon);
const wagonTypes = await wagonTypeRepo.find();
if (wagonTypes.length === 0) {
this.logger.warn('No wagon types found; skipping wagon seed');
return;
}
for (const type of wagonTypes) {
const existing = await wagonRepo.count({ where: { wagonTypeId: type.id } });
if (existing >= MIN_WAGONS_PER_TYPE) {
this.logger.log(
`Wagon type ${type.code} already has ${existing} wagons; skipping`,
);
continue;
}
const toCreate = MIN_WAGONS_PER_TYPE - existing;
const tare = Number(type.tareWeightTons ?? 20);
const maxPayload = Number(type.capacityTons ?? 60);
const rows = Array.from({ length: toCreate }, (_, i) => {
const seq = existing + i + 1;
return wagonRepo.create({
wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`,
wagonTypeId: type.id,
tareWeight: tare,
maxPayloadWeight: maxPayload,
status: WagonStatus.Available,
});
});
await wagonRepo.save(rows);
this.logger.log(`Seeded ${toCreate} wagons for type ${type.code}`);
}
}
/** Seed the default approval chains when the table is empty. */
private async seedApprovalRules(manager: EntityManager) {
const repo = manager.getRepository(ApprovalRule);
const count = await repo.count();
if (count > 0) {
this.logger.log(`Approval rules already populated (${count}); skipping`);
return;
}
await repo.save(DEFAULT_APPROVAL_RULE_ROWS.map((row) => repo.create(row)));
this.logger.log(`Seeded ${DEFAULT_APPROVAL_RULE_ROWS.length} approval rules`);
}
/** Create the four demo staff users with their roles (idempotent per email). */
private async seedStaffUsers(manager: EntityManager) {
const organization = await manager.getRepository(Organization).findOne({
where: { key: EDR_ORG_KEY },
select: { id: true, key: true },
});
if (!organization) {
this.logger.warn(`Missing organization ${EDR_ORG_KEY}; skipping staff users`);
return;
}
const roleRepo = manager.getRepository(Role);
const userRepo = manager.getRepository(User);
const credentialRepo = manager.getRepository(UserCredential);
const userRoleRepo = manager.getRepository(UserRole);
const employeeRepo = manager.getRepository(Employee);
const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678';
const hashedPassword = await hashPassword(password);
for (const staff of DEMO_STAFF_USERS) {
const role = await roleRepo.findOne({
where: { key: staff.roleKey },
select: { id: true, key: true },
});
if (!role) {
this.logger.warn(`Missing role ${staff.roleKey}; skipping ${staff.email}`);
continue;
}
let user = await userRepo.findOne({
where: { email: staff.email },
select: { id: true, email: true },
});
if (!user) {
user = await userRepo.save(
userRepo.create({
email: staff.email,
username: staff.username,
name: { en: staff.username },
isActive: true,
hasSetPassword: true,
status: EUserStatus.ACCEPTED,
}),
);
this.logger.log(`Seeded staff user ${staff.email}`);
}
const hasCredential = await credentialRepo.exists({
where: { userId: user.id, isActive: true },
});
if (!hasCredential) {
await credentialRepo.insert({
userId: user.id,
password: hashedPassword,
isActive: true,
});
}
await userRoleRepo.upsert(
{ userId: user.id, roleId: role.id, organizationId: organization.id },
{ conflictPaths: { userId: true, roleId: true } },
);
const hasEmployee = await employeeRepo.exists({
where: { userId: user.id, organizationId: organization.id, isCurrent: true },
});
if (!hasEmployee) {
await employeeRepo.insert({
userId: user.id,
organizationId: organization.id,
isCurrent: true,
name: { en: staff.username },
});
}
}
this.logger.log('Ensured demo staff users (marketing@, operations@, director@, ceo@)');
}
}

View File

@@ -212,6 +212,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
name: { en: "EDR Line Staff" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
},
{
key: "edr_operations_officer",
name: { en: "EDR Operations Officer" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer],
},
{
key: "edr_director",
name: { en: "EDR Director" },

View File

@@ -54,6 +54,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
];
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
@@ -109,6 +112,11 @@ export const FREIGHT_PERMS = {
view: 'edr_freight_app:train_scheduling:view',
manage: 'edr_freight_app:train_scheduling:manage',
},
fleet: {
view: 'edr_freight_app:fleet:view',
manage: 'edr_freight_app:fleet:manage',
},
admin: 'edr_freight_app:admin',
ruleEngine: {
view: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
@@ -121,6 +129,9 @@ const allRuleEngineViewKeys = () =>
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
export const ROLE_PERMISSION_PRESETS = {
// Marketing / line staff: drives a booking from intake through line-staff
// approval and contract generation/signing — i.e. until the contract is ready
// and signed. No director/CEO approval, no scheduling, no operations.
lineStaff: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.staffAccept,
@@ -129,8 +140,17 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
...allRuleEngineViewKeys(),
],
// Operations Officer: train scheduling + wagon allocation + transit/complete
// + fleet management (wagons, trains, locomotives, routes, containers, cargo).
operationsOfficer: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.trainScheduling.manage,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...allRuleEngineViewKeys(),
],
director: [
@@ -147,8 +167,15 @@ export const ROLE_PERMISSION_PRESETS = {
...allRuleEngineViewKeys(),
],
finance: [FREIGHT_PERMS.bookings.view],
// Marketing handles intake through contract (same as line staff here).
marketing: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.staffAccept,
FREIGHT_PERMS.bookings.requestChanges,
FREIGHT_PERMS.bookings.reject,
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.generateContract,
FREIGHT_PERMS.bookings.signStaff,
],

View File

@@ -13,6 +13,7 @@ import {
Container,
Package,
Users,
Wallet,
//TrainTrack,
} from "lucide-react";
@@ -23,6 +24,7 @@ import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
@@ -47,6 +49,8 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
import { RequirePermission } from "./components/auth/RequirePermission";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage";
@@ -70,6 +74,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/booking-requests",
icon: <FileText />,
},
{
label: "Payments",
href: "/dashboard/payments",
icon: <Wallet />,
permission: FREIGHT_PERMS.bookings.view,
},
...demoItems,
],
},
@@ -80,11 +90,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Batch Board",
href: "/dashboard/operations/batch-board",
icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
],
},
@@ -95,11 +107,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: FREIGHT_PERMS.fleet.view,
},
// {
// label: "Trains",
@@ -115,6 +129,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
// {
// label: "Containers",
@@ -135,6 +150,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
permission: FREIGHT_PERMS.admin,
children: [
{
label: "Users",
@@ -162,11 +178,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
permission: FREIGHT_PERMS.admin,
},
],
},
@@ -196,18 +214,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
];
const hasPermission = (
/** Keep only items the user is permitted to see; drop now-empty sections. */
const filterSidebarByPermission = (
sections: SidebarSection[],
user: ReturnType<typeof useAuth>["user"],
key: string,
) => {
if (!user) return false;
if (user.permissions?.some((p) => p.key === key)) return true;
): SidebarSection[] => {
const itemAllowed = (item: SidebarItem): boolean => {
if (!item.permission) return true;
const keys = Array.isArray(item.permission)
? item.permission
: [item.permission];
return keys.some((key) => hasFreightPermission(user, key));
};
return (user.employee ?? []).some((emp) =>
(emp.positions ?? []).some((pos) =>
(pos.permissions ?? []).some((p) => p.key === key),
),
);
return sections
.map((section) => ({
...section,
items: section.items.filter(itemAllowed),
}))
.filter((section) => section.items.length > 0);
};
const DashboardShell = () => {
@@ -217,7 +242,10 @@ const DashboardShell = () => {
const demoItems: SidebarItem[] = [];
const sidebarSections = buildSidebarSections(demoItems);
const sidebarSections = filterSidebarByPermission(
buildSidebarSections(demoItems),
user,
);
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
@@ -261,6 +289,14 @@ const App = () => {
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route
path="payments"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<PaymentsPage />
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
@@ -271,30 +307,102 @@ const App = () => {
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
/>
<Route path="operations/batch-board" element={<BatchBoardPage />} />
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={<BatchScheduleDetailPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={<TrainScheduleV2ListPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={<TrainScheduleV2DetailPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={<TrainScheduleTrackPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route path="routes" element={<RoutesPage />} />
<Route path="locomotives" element={<FleetResourcePage />} />
<Route path="trains" element={<FleetResourcePage />} />
<Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="wagons" element={<FleetResourcePage />} />
<Route path="containers" element={<FleetResourcePage />} />
<Route path="cargoes" element={<FleetResourcePage />} />
{/* iframe-based user management module */}
<Route path="um/*" element={<UserManagementHostPage />} />
@@ -307,8 +415,22 @@ const App = () => {
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route
path="file-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<FileUploadSettingsPage />
</RequirePermission>
}
/>
<Route
path="dropdown-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<DropdownSettingsPage />
</RequirePermission>
}
/>
<Route
path="configuration"
@@ -316,7 +438,11 @@ const App = () => {
/>
<Route
path="configuration/train-scheduling-rules"
element={<TrainSchedulingGlobalRulesPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainSchedulingGlobalRulesPage />
</RequirePermission>
}
/>
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { hasPermission } from "@/lib/permissions";
interface RequirePermissionProps {
/** Permission key(s); access is granted if the user has ANY of them. */
permission: string | string[];
/** Where to send users who lack the permission. */
redirectTo?: string;
children: ReactNode;
}
/**
* Page-level guard: renders children only when the current user holds one of
* the given permissions, otherwise redirects (default: overview).
*/
export function RequirePermission({
permission,
redirectTo = "/dashboard/overview",
children,
}: RequirePermissionProps) {
const { user } = useAuth();
const keys = Array.isArray(permission) ? permission : [permission];
const allowed = keys.some((key) => hasPermission(user, key));
if (!allowed) return <Navigate to={redirectTo} replace />;
return <>{children}</>;
}

View File

@@ -8,6 +8,8 @@ import { BookingActionsMenu } from "./BookingActionsMenu";
import { SectionCard } from "./detail/SectionCard";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
import { useAuth } from "@/auth/useAuth";
import { canManageScheduling } from "@/lib/permissions";
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
type Mutations = ReturnType<typeof useBookingMutations>;
@@ -19,9 +21,11 @@ interface BookingActionsToolbarProps {
/** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
const { user } = useAuth();
const row = toBookingListRow(booking);
const { status } = booking;
const [allocateOpen, setAllocateOpen] = useState(false);
const canAllocate = canManageScheduling(user);
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
@@ -127,7 +131,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
</SectionCard>
)}
{canAllocateBooking(booking) ? (
{canAllocate && canAllocateBooking(booking) ? (
<AllocateBookingWizard
booking={booking}
opened={allocateOpen}

View File

@@ -0,0 +1,102 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
FileCheck,
Mail,
MapPin,
Phone,
User,
} from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
export interface BookingCompanyCardProps {
booking: BookingDetail;
}
/** Customer (company) information for the booking. */
export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
const company = booking.company;
// Government bookings may not carry a company; show the institution instead.
if (!company && booking.isGovernment) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<InfoRow
icon={Building2}
label="Government"
value={booking.governmentInstitution}
/>
</SectionCard>
);
}
if (!company) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<Text size="sm" c="dimmed">
No customer linked to this booking.
</Text>
</SectionCard>
);
}
const companyName = company.companyName ?? company.name ?? company.label;
const rows: InfoRowProps[] = [
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact person", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
].filter((r) => r.value);
return (
<SectionCard
icon={Building2}
title="Customer"
subtitle={companyName}
accent="blue"
>
<Stack gap={0}>
{rows.length === 0 ? (
<Text size="sm" c="dimmed">
No additional company details available.
</Text>
) : (
rows.map((row, index) => (
<div key={row.label}>
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))
)}
</Stack>
</SectionCard>
);
}

View File

@@ -122,6 +122,7 @@ export interface BookingFileView {
id: string;
name: string;
mimeType?: string;
code?: string;
}
export interface BookingDetailView {

View File

@@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard";
export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard";
export * from "./BookingContractSummaryCard";
export * from "./BookingCompanyCard";

View File

@@ -43,6 +43,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage your account and signature",
},
},
{
prefix: "/dashboard/payments",
meta: {
title: "Payments",
subtitle: "View booking payment transactions",
},
},
{
prefix: "/dashboard/operations/train-scheduling-v2/",
meta: {

View File

@@ -6,6 +6,8 @@ export interface SidebarItem {
href?: string;
icon?: ReactNode;
children?: SidebarItem[];
/** Permission key(s) required to see this item; ANY grants access. */
permission?: string | string[];
}
export interface SidebarSection {

View File

@@ -124,6 +124,11 @@ export const URL_CONSTANTS = {
VERIFY: "/api/otp/verify",
},
PAYMENTS: {
ALL: "/payments/all",
SUMMARY: "/payments/summary",
},
LOCOMOTIVES: {
BASE: "/locomotives",
BY_ID: (id: string) => `/locomotives/${id}`,

View File

@@ -1 +1,3 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -201,6 +201,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
startTransit: FREIGHT_PERMS.bookings.operations,
complete: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
cancel: FREIGHT_PERMS.bookings.cancel,
};

View File

@@ -0,0 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import {
paymentsService,
type PaymentListFilter,
} from "@/services/payments.service";
export function usePaymentList(filter?: PaymentListFilter, enabled = true) {
return useQuery({
queryKey: ["payments", "list", filter ?? {}],
queryFn: () => paymentsService.list(filter),
enabled,
});
}
export function usePaymentSummary(enabled = true) {
return useQuery({
queryKey: ["payments", "summary"],
queryFn: () => paymentsService.getSummary(),
staleTime: 30_000,
enabled,
});
}

View File

@@ -16,6 +16,15 @@ export const FREIGHT_PERMS = {
operations: "edr_freight_app:bookings:operations",
cancel: "edr_freight_app:bookings:cancel",
},
trainScheduling: {
view: "edr_freight_app:train_scheduling:view",
manage: "edr_freight_app:train_scheduling:manage",
},
fleet: {
view: "edr_freight_app:fleet:view",
manage: "edr_freight_app:fleet:manage",
},
admin: "edr_freight_app:admin",
} as const;
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
@@ -70,6 +79,22 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
export function canViewScheduling(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
}
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.trainScheduling.manage);
}
export function canViewFleet(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.fleet.view);
}
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.admin);
}
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
}

View File

@@ -50,11 +50,8 @@ export default function BookingContractPage() {
enabled: Boolean(id),
});
const signRole: "CUSTOMER" | "STAFF" | null = data?.canSignCustomer
? "CUSTOMER"
: data?.canSignStaff
? "STAFF"
: null;
// Backoffice only ever signs as STAFF — customers sign in the portal.
const canSign = Boolean(data?.canSignStaff);
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
@@ -106,12 +103,12 @@ export default function BookingContractPage() {
};
const confirmSign = () => {
if (!signRole || !signerName.trim()) return;
if (!canSign || !signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate({
role: signRole,
role: "STAFF",
signatureImageBase64: image,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
@@ -167,10 +164,10 @@ export default function BookingContractPage() {
<Download className="size-4" />
Download PDF
</Button>
{signRole && (
{canSign && (
<Button size="sm" className="gap-2" onClick={openSign}>
<FileSignature className="size-4" />
Sign as {signRole === "CUSTOMER" ? "Customer" : "Staff"}
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
)}
</div>
@@ -194,9 +191,7 @@ export default function BookingContractPage() {
<Dialog open={signOpen} onOpenChange={setSignOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
</DialogTitle>
<DialogTitle>Staff signature</DialogTitle>
<DialogDescription>
{usingSaved
? `Review your saved signature and approve it to execute the contract for ${data.reference}.`

View File

@@ -24,11 +24,25 @@ import {
BookingRouteServiceCard,
BookingMileServicesCard,
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingDocumentsCard,
type BookingFileView,
} from "@/components/bookings/detail";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { downloadBookingFile } from "@/services/files.service";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
import toast from "react-hot-toast";
// Signature / generated-contract files are surfaced on the contract page, not
// in the booking's Documents list.
const SIGNATURE_FILE_CODES = new Set([
"signature",
"signature_customer",
"signature_staff",
"contract",
]);
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -36,6 +50,14 @@ export default function BookingRequestDetailPage() {
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
const handleDownloadFile = async (file: BookingFileView) => {
try {
await downloadBookingFile(file.id, file.name);
} catch {
toast.error("Could not download file.");
}
};
if (isLoading) {
return (
<Box style={detailStyles.page}>
@@ -147,6 +169,12 @@ export default function BookingRequestDetailPage() {
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={handleDownloadFile}
/>
</Stack>
</Grid.Col>
@@ -154,6 +182,7 @@ export default function BookingRequestDetailPage() {
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} />
<BookingActionsToolbar booking={booking} mutations={mutations} />
{showContractButton && (

View File

@@ -0,0 +1,367 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Box,
Card,
Container,
Group,
Paper,
Select,
Stack,
Tabs,
Text,
TextInput,
} from "@mantine/core";
import {
CheckCircle2,
CircleDollarSign,
Loader2,
RotateCcw,
Search,
X,
XCircle,
type LucideIcon,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
import type {
PaymentMethod,
PaymentRow,
} from "@/services/payments.service";
import { cn } from "@/lib/utils";
import {
Badge,
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
const STATUS_TABS = [
{ key: "all", label: "All", statuses: undefined as string | undefined },
{ key: "success", label: "Success", statuses: "success" },
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
{ key: "refunded", label: "Refunded", statuses: "refunded" },
] as const;
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
{ value: "telebirr", label: "Telebirr" },
{ value: "waafi", label: "Waafi" },
{ value: "cbe-birr", label: "CBE Birr" },
{ value: "ebirr", label: "E-Birr" },
{ value: "card", label: "Card" },
{ value: "dmoney", label: "D-Money" },
{ value: "cac-bank", label: "CAC Bank" },
];
const STATUS_COLORS: Record<string, string> = {
success: "green",
processing: "yellow",
"action-required": "yellow",
failed: "red",
canceled: "gray",
refunded: "indigo",
};
function StatCard({
icon: Icon,
label,
value,
accent,
}: {
icon: LucideIcon;
label: string;
value: string | number;
accent: string;
}) {
return (
<Paper
p="md"
radius="lg"
style={{
flex: "1 1 180px",
minWidth: 160,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 11,
background: `var(--mantine-color-${accent}-1)`,
color: `var(--mantine-color-${accent}-7)`,
flexShrink: 0,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
{value}
</Text>
<Text size="xs" fw={600} c="dimmed" truncate>
{label}
</Text>
</Stack>
</Group>
</Paper>
);
}
function formatAmount(amount: number, currency: string): string {
return `${currency} ${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`;
}
function formatDate(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
export default function PaymentsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [statusTab, setStatusTab] = useState<StatusTabKey>("all");
const [method, setMethod] = useState<string | null>(null);
const statuses = STATUS_TABS.find((t) => t.key === statusTab)?.statuses;
const filter = useMemo(
() => ({
search: query.trim() || undefined,
status: statuses,
method: method ?? undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
}),
[query, statuses, method, pagination.pageIndex, pagination.pageSize],
);
const { data, isLoading, isError } = usePaymentList(filter);
const { data: summary, isLoading: summaryLoading } = usePaymentSummary();
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
const columns: ColumnDef<PaymentRow>[] = [
{
id: "order",
header: () => <span className={tableHeader}>Order</span>,
cell: ({ row }) => (
<div className="min-w-0 py-1">
<p className="truncate font-medium text-foreground">
{row.original.merchantOrderId ?? row.original.id.slice(0, 8)}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
Booking {row.original.bookingId?.slice(0, 8) ?? "—"}
</p>
</div>
),
},
{
id: "amount",
header: () => <span className={tableHeader}>Amount</span>,
cell: ({ row }) => (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{formatAmount(row.original.amount, row.original.currency)}
</span>
),
},
{
id: "method",
header: () => <span className={tableHeader}>Method</span>,
cell: ({ row }) => (
<Badge variant="light" radius="sm">
{METHOD_OPTIONS.find((m) => m.value === row.original.method)?.label ??
row.original.method}
</Badge>
),
},
{
id: "status",
header: () => <span className={tableHeader}>Status</span>,
cell: ({ row }) => (
<Badge
color={STATUS_COLORS[row.original.status] ?? "gray"}
variant="light"
radius="sm"
tt="capitalize"
>
{row.original.status.replace(/-/g, " ")}
</Badge>
),
},
{
id: "date",
header: () => <span className={tableHeader}>Date</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDate(row.original.paidAt ?? row.original.createdAt)}
</span>
),
},
];
return (
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Payments" }]} />
<Stack gap="lg" mt="md">
<Group grow gap="md" align="stretch" wrap="wrap">
<StatCard
icon={CircleDollarSign}
label="Total collected"
value={
summaryLoading
? "—"
: `ETB ${Number(summary?.paidAmount ?? 0).toLocaleString()}`
}
accent="teal"
/>
<StatCard
icon={CheckCircle2}
label="Successful"
value={val(summary?.success)}
accent="green"
/>
<StatCard
icon={Loader2}
label="Processing"
value={val(summary?.processing)}
accent="yellow"
/>
<StatCard
icon={XCircle}
label="Failed"
value={val(summary?.failed)}
accent="red"
/>
<StatCard
icon={RotateCcw}
label="Refunded"
value={val(summary?.refunded)}
accent="indigo"
/>
</Group>
<Tabs
value={statusTab}
onChange={(value) => {
setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
>
<Tabs.List>
{STATUS_TABS.map((t) => (
<Tabs.Tab key={t.key} value={t.key}>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
<Card
p="md"
radius="lg"
withBorder
style={{ background: "white", border: "1px solid var(--mantine-color-gray-2)" }}
>
<Stack gap="md">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search order, booking, or transaction…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
placeholder="All methods"
clearable
data={METHOD_OPTIONS}
value={method}
onChange={(value) => {
setMethod(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
radius="lg"
style={{ minWidth: 180 }}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<div style={{ overflowX: "auto" }}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
)}
footer={DataTableFooter}
/>
</div>
</Stack>
</Card>
</Stack>
</Container>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { api as client } from "../auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
const F = URL_CONSTANTS.FILES;
export const filesService = {
/** Stream a stored file by id (backend route: GET /files/:id). */
download: async (id: string): Promise<Blob> => {
const response = await client.get(F.BY_ID(id), { responseType: "blob" });
return response.data as Blob;
},
};
/** Download a file blob and trigger a browser save with the given name. */
export async function downloadBookingFile(
id: string,
filename: string,
): Promise<void> {
const blob = await filesService.download(id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,84 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
const P = URL_CONSTANTS.PAYMENTS;
export type PaymentStatus =
| "action-required"
| "processing"
| "success"
| "failed"
| "canceled"
| "refunded";
export type PaymentMethod =
| "telebirr"
| "cbe-birr"
| "ebirr"
| "waafi"
| "card"
| "dmoney"
| "cac-bank";
export interface PaymentRow {
id: string;
bookingId: string;
amount: number;
currency: string;
method: PaymentMethod;
status: PaymentStatus;
merchantOrderId: string | null;
paidAt: string | null;
createdAt: string;
}
export interface PaymentListFilter {
search?: string;
status?: string;
method?: string;
page?: number;
pageSize?: number;
}
export interface PaginatedPayments {
items: PaymentRow[];
total: number;
page: number;
pageSize: number;
}
export interface PaymentSummary {
total: number;
success: number;
processing: number;
failed: number;
refunded: number;
paidAmount: number;
}
export const paymentsService = {
list: async (filter?: PaymentListFilter): Promise<PaginatedPayments> => {
const params: Record<string, string | number | undefined> = {};
if (filter) {
if (filter.search) params.search = filter.search;
if (filter.status) params.status = filter.status;
if (filter.method) params.method = filter.method;
if (filter.page != null) params.page = filter.page;
if (filter.pageSize != null) params.pageSize = filter.pageSize;
}
const response = await client.get<PaginatedPayments>(P.ALL, { params });
const data = unwrap(response.data) as PaginatedPayments;
return {
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? 1,
pageSize: data.pageSize ?? 10,
};
},
getSummary: async (): Promise<PaymentSummary> => {
const response = await client.get<PaymentSummary>(P.SUMMARY);
return unwrap(response.data) as PaymentSummary;
},
};

View File

@@ -30,6 +30,27 @@ export interface BookingNamedRef {
companyName?: string;
}
/** Full company record the booking response joins in (subset used by the UI). */
export interface BookingCompany {
id: string;
name?: string;
type?: string;
status?: string;
tin?: string | null;
vatNumber?: string | null;
businessLicense?: string | null;
country?: string | null;
address?: string | null;
phone?: string | null;
email?: string | null;
contactPersonName?: string | null;
contactPersonPhone?: string | null;
generalManagerName?: string | null;
generalManagerEmail?: string | null;
generalManagerPhone?: string | null;
website?: string | null;
}
export interface BookingContainerLine {
id: string;
containerTypeId: string;
@@ -81,6 +102,8 @@ export interface BookingFile {
name: string;
mimeType?: string;
code?: string;
url?: string;
size?: number;
}
export interface BookingDetail {
@@ -121,7 +144,7 @@ export interface BookingDetail {
createdAt: string;
updatedAt: string;
// customer?: BookingNamedRef & { companyName?: string };
company?: BookingNamedRef;
company?: BookingNamedRef & Partial<BookingCompany>;
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number };

View File

@@ -22,6 +22,7 @@ import useAuth from "./hooks/useAuth";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import ProfilePage from "./pages/ProfilePage";
import MySignaturePage from "./pages/MySignaturePage";
import SettingsPage from "./pages/SettingsPage";
import LoginPage from "./pages/accounts/LoginPage";
import OnboardingPage from "./pages/accounts/OnboardingPage";
@@ -201,6 +202,7 @@ const App = () => {
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/signature" element={<MySignaturePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>

View File

@@ -18,6 +18,7 @@ import { useDisclosure } from "@mantine/hooks";
import {
Bell,
ChevronDown,
FileSignature,
LogOut,
Menu as MenuIcon,
Moon,
@@ -312,6 +313,12 @@ export function AppLayout({
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/signature")}
>
My signature
</Menu.Item>
<Menu.Item
leftSection={<Settings size={15} />}
onClick={() => navigate("/settings")}

View File

@@ -0,0 +1,141 @@
import { useState } from "react";
import { FileSignature, Loader2 } from "lucide-react";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import useAuth from "@/hooks/useAuth";
import {
useMySignature,
useSaveSignature,
} from "@/hooks/useSavedSignature";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@edr/ui-common";
/**
* Lets the signed-in customer view and update the reusable signature stored on
* their profile. The same signature is offered for approval when signing a
* booking contract.
*/
export function MySignatureCard() {
const { user } = useAuth();
const { data: saved, isPending } = useMySignature();
const saveMutation = useSaveSignature();
const [open, setOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const defaultName = user?.name?.en || user?.username || user?.email || "";
const openDialog = () => {
setSignerName(saved?.signerDisplayName ?? defaultName);
setSignatureData(null);
setOpen(true);
};
const save = () => {
if (!signatureData || !signerName.trim()) return;
saveMutation.mutate(
{
signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData,
},
{ onSuccess: () => setOpen(false) },
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSignature className="size-5 text-primary" />
My signature
</CardTitle>
<CardDescription>
Reused to approve and sign booking contracts.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isPending ? (
<div className="flex h-36 items-center justify-center">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : saved?.signatureImageUrl ? (
<div className="flex flex-col gap-2">
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</CardContent>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save your signature</DialogTitle>
<DialogDescription>
Draw your signature below. It will be stored on your profile for
future contracts.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="profileSignerName">Full name</Label>
<Input
id="profileSignerName"
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
placeholder="As shown on contracts"
/>
</div>
<ContractSignaturePad onChange={setSignatureData} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={
saveMutation.isPending || !signatureData || !signerName.trim()
}
onClick={save}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save signature"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -1 +1,3 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -0,0 +1,30 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
signaturesService,
type SaveSignaturePayload,
} from "@/services/signatures.service";
const SAVED_SIGNATURE_KEY = ["me", "signature"] as const;
export function useMySignature() {
return useQuery({
queryKey: SAVED_SIGNATURE_KEY,
queryFn: () => signaturesService.getMySignature(),
staleTime: 60_000,
});
}
export function useSaveSignature() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: SaveSignaturePayload) =>
signaturesService.saveMySignature(payload),
onSuccess: () => {
toast.success("Signature saved");
void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY });
},
onError: () => toast.error("Failed to save signature"),
});
}

View File

@@ -0,0 +1,19 @@
import { MySignatureCard } from "@/components/profile/MySignatureCard";
export default function MySignaturePage() {
return (
<div className="px-4 py-8">
<div className="mx-auto flex max-w-md flex-col gap-6">
<div>
<h1 className="text-2xl font-black tracking-tight text-foreground">
My signature
</h1>
<p className="text-sm text-muted-foreground">
Saved and reused to approve and sign booking contracts.
</p>
</div>
<MySignatureCard />
</div>
</div>
);
}

View File

@@ -25,6 +25,9 @@ export default function BookingContractPage() {
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// When a saved signature exists we offer it for approval first; the customer
// can switch to drawing a fresh one.
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["booking-contract-view", id],
@@ -32,6 +35,31 @@ export default function BookingContractPage() {
enabled: Boolean(id),
});
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const openSign = () => {
// Prefill from the saved signature so the customer only has to approve it.
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate({
role: "CUSTOMER",
signatureImageBase64: image,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
});
};
const signMutation = useMutation({
mutationFn: (payload: SignContractPayload) =>
bookingsService.signContract(id!, payload),
@@ -102,9 +130,9 @@ export default function BookingContractPage() {
PDF
</Button>
{data.canSignCustomer && (
<Button size="sm" onClick={() => setSignOpen(true)}>
<Button size="sm" onClick={openSign}>
<FileSignature className="mr-2 size-4" />
Sign contract
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
)}
</div>
@@ -122,9 +150,13 @@ export default function BookingContractPage() {
{signOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
<h2 className="text-lg font-semibold">Sign contract</h2>
<h2 className="text-lg font-semibold">
{usingSaved ? "Approve signature" : "Sign contract"}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{data.reference} your signature will be stored securely.
{usingSaved
? `${data.reference} — review your saved signature and approve it.`
: `${data.reference} — your signature will be stored securely.`}
</p>
<div className="mt-4 space-y-3">
<label className="text-sm font-medium" htmlFor="portalSigner">
@@ -136,7 +168,29 @@ export default function BookingContractPage() {
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
/>
<ContractSignaturePad onChange={setSignatureData} />
{usingSaved ? (
<div className="space-y-2">
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={savedSignatureImage ?? undefined}
alt="Saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<button
type="button"
className="text-xs text-primary underline"
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</button>
</div>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={() => setSignOpen(false)}>
@@ -145,19 +199,12 @@ export default function BookingContractPage() {
<Button
disabled={
signMutation.isPending ||
!signatureData ||
(!usingSaved && !signatureData) ||
!signerName.trim()
}
onClick={() =>
signMutation.mutate({
role: "CUSTOMER",
signatureImageBase64: signatureData!,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
})
}
onClick={confirmSign}
>
Confirm signature
{usingSaved ? "Approve & sign" : "Confirm signature"}
</Button>
</div>
</div>

View File

@@ -28,17 +28,18 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
const status = booking.status as string;
const [payModalOpen, setPayModalOpen] = useState(false);
// Two-step flow: POST /payments/initiate to create the intent, then send the
// browser to the public /payments/checkout page which redirects to the
// selected provider to complete payment.
// POST /payments/initiate creates the intent and returns the provider's
// redirect URL (clientAction.url). Send the browser straight there; fall back
// to the public /payments/checkout page if no redirect URL came back.
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) =>
api.payments.initiate.call({ bookingId: booking.id, method }),
onSuccess: (_data, method) => {
window.location.href = paymentsService.checkoutUrl({
bookingId: booking.id,
method,
});
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrl({ bookingId: booking.id, method });
window.location.href = redirectUrl;
},
});

View File

@@ -1,12 +1,5 @@
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
import {
Banknote,
Building2,
CreditCard,
Smartphone,
Wallet,
type LucideIcon,
} from "lucide-react";
import { Smartphone, type LucideIcon } from "lucide-react";
import { useState } from "react";
import type { PaymentMethod } from "@/services/payments.service";
@@ -18,6 +11,7 @@ interface ProviderOption {
icon: LucideIcon;
}
// Only Telebirr and Waafi are enabled for now.
const PROVIDERS: ProviderOption[] = [
{
method: "TELEBIRR",
@@ -25,42 +19,12 @@ const PROVIDERS: ProviderOption[] = [
description: "Ethiopian mobile money",
icon: Smartphone,
},
{
method: "CBE_BIRR",
label: "CBE Birr",
description: "Commercial Bank of Ethiopia",
icon: Building2,
},
{
method: "EBIRR",
label: "E-Birr",
description: "Electronic payment gateway",
icon: Wallet,
},
{
method: "WAAFI",
label: "WAAFI",
label: "Waafi",
description: "Djibouti mobile money",
icon: Smartphone,
},
{
method: "CARD",
label: "Card",
description: "Visa / Mastercard",
icon: CreditCard,
},
{
method: "DMONEY",
label: "D-Money",
description: "Djibouti D-money",
icon: Banknote,
},
{
method: "CAC_BANK",
label: "CAC Bank",
description: "CAC Int Bank (OTP)",
icon: Building2,
},
];
function ProviderRow({
@@ -141,7 +105,7 @@ export function PaymentMethodModal({
processing?: boolean;
error?: string | null;
}) {
const [method, setMethod] = useState<PaymentMethod | null>(null);
const [method, setMethod] = useState<PaymentMethod>(PROVIDERS[0].method);
return (
<Modal
@@ -184,9 +148,9 @@ export function PaymentMethodModal({
mt={6}
radius={10}
color="edr-green"
disabled={!method || processing}
disabled={processing}
loading={processing}
onClick={() => method && onConfirm(method)}
onClick={() => onConfirm(method)}
styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 },

View File

@@ -23,6 +23,11 @@ export interface ContractView {
signedAt: string;
signatureImageUrl?: string | null;
}>;
/** Current viewer's reusable saved signature, if they have one. */
savedSignature?: {
signerDisplayName: string;
signatureImageUrl?: string | null;
} | null;
}
export interface PriceLineItem {

View File

@@ -0,0 +1,28 @@
import { client } from "../utils/api";
const SIGNATURE_URL = "/api/me/signature";
export interface SavedSignature {
signerDisplayName: string;
signatureImageUrl?: string | null;
}
export interface SaveSignaturePayload {
signerDisplayName: string;
signatureImageBase64: string;
}
export const signaturesService = {
/** The current user's reusable saved signature, or null if none. */
getMySignature: async (): Promise<SavedSignature | null> => {
const { data } = await client.get(SIGNATURE_URL);
return (data.data ?? data) ?? null;
},
saveMySignature: async (
payload: SaveSignaturePayload,
): Promise<SavedSignature | null> => {
const { data } = await client.put(SIGNATURE_URL, payload);
return (data.data ?? data) ?? null;
},
};