diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 32237b507..3ddef331e 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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": { diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 30b25e013..4d855e055 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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(); } } diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 393a97f9f..8d55f1dc4 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts index 395ff0386..b3305ba68 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 0091b5341..5a801cf73 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts index b0babb06f..7f3f06ec2 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 5b38c4d65..81fba19fb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { 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 { @@ -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 { 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 { diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index 46ef22bf8..b107e8935 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts index efffb075e..1a0cdb14f 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts index 404e4d27b..7451bd6b6 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts index e8c3fbba0..7a63964d8 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts index ecdecffc3..661339902 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts index f7ccdde1d..c907af717 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 736f5d274..14308883d 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 3f90628f0..b24febf91 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -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 = {}; + 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 { const booking = await this.datasource .getRepository(Booking) diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index 4af088727..8c25d67b3 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts index 6ff10f7d5..7137ab6a5 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 3ef7693ab..2a4c3a357 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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", }) diff --git a/apps/edr-freight-api/src/modules/trains/trains.controller.ts b/apps/edr-freight-api/src/modules/trains/trains.controller.ts index c58fc086e..0217bc161 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index c70208052..ec98a4a4b 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -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 train‑specific 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); diff --git a/apps/edr-freight-api/src/scripts/seed-freight-demo.ts b/apps/edr-freight-api/src/scripts/seed-freight-demo.ts new file mode 100644 index 000000000..8f4e8d0b2 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-freight-demo.ts @@ -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); +}); diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts new file mode 100644 index 000000000..f4931f77b --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -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@)'); + } +} diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index c2705673f..a88ee8f89 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -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" }, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 0ee62fa7c..ed0a494ab 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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 = { @@ -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, ], diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index cfe22845d..a384d5956 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: , }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + 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: , + permission: FREIGHT_PERMS.trainScheduling.view, }, { label: "Batch Board", href: "/dashboard/operations/batch-board", icon: , + permission: FREIGHT_PERMS.trainScheduling.view, }, ], }, @@ -95,11 +107,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Routes", href: "/dashboard/routes", icon: , + permission: FREIGHT_PERMS.fleet.view, }, { label: "Locomotives", href: "/dashboard/locomotives", icon: , + permission: FREIGHT_PERMS.fleet.view, }, // { // label: "Trains", @@ -115,6 +129,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Wagons", href: "/dashboard/wagons", icon: , + permission: FREIGHT_PERMS.fleet.view, }, // { // label: "Containers", @@ -135,6 +150,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "User management", href: "/dashboard/user-management", icon: , + permission: FREIGHT_PERMS.admin, children: [ { label: "Users", @@ -162,11 +178,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "File settings", href: "/dashboard/file-settings", icon: , + permission: FREIGHT_PERMS.admin, }, { label: "Dropdown settings", href: "/dashboard/dropdown-settings", icon: , + 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["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 = () => { } /> } /> + + + + } + /> } /> } /> { path="operations/train-scheduling" element={} /> - } /> + + + + } + /> } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> {/* iframe-based user management module */} } /> @@ -307,8 +415,22 @@ const App = () => { } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> { /> } + element={ + + + + } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx new file mode 100644 index 000000000..4e35fc712 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx @@ -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 ; + return <>{children}; +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index 0c20184cf..4589ad7fb 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -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; @@ -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, filename: string) => { const blob = await fn(); @@ -127,7 +131,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool )} - {canAllocateBooking(booking) ? ( + {canAllocate && canAllocateBooking(booking) ? ( + + + + {label} + + + + {value || "—"} + + + ); +} + +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 ( + + + + ); + } + + if (!company) { + return ( + + + No customer linked to this booking. + + + ); + } + + 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 ( + + + {rows.length === 0 ? ( + + No additional company details available. + + ) : ( + rows.map((row, index) => ( +
+ {index > 0 && } + +
+ )) + )} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts index c53cbccbd..80fc527cb 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts @@ -122,6 +122,7 @@ export interface BookingFileView { id: string; name: string; mimeType?: string; + code?: string; } export interface BookingDetailView { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index 36d782730..001bc6976 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard"; export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; export * from "./BookingContractSummaryCard"; +export * from "./BookingCompanyCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 3c421090a..879292143 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -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: { diff --git a/apps/edr-freight-web/backoffice/src/components/layout/types.ts b/apps/edr-freight-web/backoffice/src/components/layout/types.ts index ba37f33e5..051f28839 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/types.ts @@ -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 { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 99f817b74..c87cf300c 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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}`, diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 02bf46ac9..030b051a1 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1 +1,3 @@ export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; + +// export const API_BASE_URL = 'http://localhost:3001'; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index 5aeeabbd7..cdb0d3d83 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -201,6 +201,7 @@ const ACTION_PERMISSION: Partial> = { 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, }; diff --git a/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts b/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts new file mode 100644 index 000000000..e9a09760b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts @@ -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, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index cadaaea03..7f3b2ac90 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -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`; } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx index 4689d28fa..6ea4c40e9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx @@ -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 PDF - {signRole && ( + {canSign && ( )} @@ -194,9 +191,7 @@ export default function BookingContractPage() { - - {signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"} - + Staff signature {usingSaved ? `Review your saved signature and approve it to execute the contract for ${data.reference}.` diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index b534b44cc..73de7ee8f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -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 ( @@ -147,6 +169,12 @@ export default function BookingRequestDetailPage() { {booking.contractSummary && ( )} + !SIGNATURE_FILE_CODES.has(f.code ?? ""), + )} + onDownload={handleDownloadFile} + /> @@ -154,6 +182,7 @@ export default function BookingRequestDetailPage() { + {showContractButton && ( diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx new file mode 100644 index 000000000..07e212b76 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -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 = { + 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 ( + + + + + + + + {value} + + + {label} + + + + + ); +} + +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("all"); + const [method, setMethod] = useState(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[] = [ + { + id: "order", + header: () => Order, + cell: ({ row }) => ( +
+

+ {row.original.merchantOrderId ?? row.original.id.slice(0, 8)} +

+

+ Booking {row.original.bookingId?.slice(0, 8) ?? "—"} +

+
+ ), + }, + { + id: "amount", + header: () => Amount, + cell: ({ row }) => ( + + {formatAmount(row.original.amount, row.original.currency)} + + ), + }, + { + id: "method", + header: () => Method, + cell: ({ row }) => ( + + {METHOD_OPTIONS.find((m) => m.value === row.original.method)?.label ?? + row.original.method} + + ), + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( + + {row.original.status.replace(/-/g, " ")} + + ), + }, + { + id: "date", + header: () => Date, + cell: ({ row }) => ( + + {formatDate(row.original.paidAt ?? row.original.createdAt)} + + ), + }, + ]; + + return ( +
+ + + + + + + + + + + + + { + setStatusTab((value as StatusTabKey) ?? "all"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + > + + {STATUS_TABS.map((t) => ( + + {t.label} + + ))} + + + + + + + } + value={query} + onChange={(e) => { + setQuery(e.target.value); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + rightSection={ + query && ( + setQuery("")} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> + setSignerName(e.target.value)} + placeholder="As shown on contracts" + /> +
+ + + + + + +
+
+ + ); +} diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index 02bf46ac9..dce8aad63 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1 +1,3 @@ export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'http://localhost:3001'; + diff --git a/apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts b/apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts new file mode 100644 index 000000000..b8c9480a7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts @@ -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"), + }); +} diff --git a/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx b/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx new file mode 100644 index 000000000..4c30c878a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx @@ -0,0 +1,19 @@ +import { MySignatureCard } from "@/components/profile/MySignatureCard"; + +export default function MySignaturePage() { + return ( +
+
+
+

+ My signature +

+

+ Saved and reused to approve and sign booking contracts. +

+
+ +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx index dd1916093..062a8a766 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx @@ -25,6 +25,9 @@ export default function BookingContractPage() { const [signOpen, setSignOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(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 {data.canSignCustomer && ( - )} @@ -122,9 +150,13 @@ export default function BookingContractPage() { {signOpen && (
-

Sign contract

+

+ {usingSaved ? "Approve signature" : "Sign contract"} +

- {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.`}

diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 622227674..5998ea2fa 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -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; }, }); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 18ab3a36c..8e734229b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -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(null); + const [method, setMethod] = useState(PROVIDERS[0].method); return ( method && onConfirm(method)} + onClick={() => onConfirm(method)} styles={{ root: { height: 46 }, label: { fontSize: 14, fontWeight: 800 }, diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index ac6411505..9933fa227 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -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 { diff --git a/apps/edr-freight-web/portal/src/services/signatures.service.ts b/apps/edr-freight-web/portal/src/services/signatures.service.ts new file mode 100644 index 000000000..05ae61ea1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/signatures.service.ts @@ -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 => { + const { data } = await client.get(SIGNATURE_URL); + return (data.data ?? data) ?? null; + }, + + saveMySignature: async ( + payload: SaveSignaturePayload, + ): Promise => { + const { data } = await client.put(SIGNATURE_URL, payload); + return (data.data ?? data) ?? null; + }, +};