mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
export class PaymentClientService {
|
||||
private readonly logger = new Logger(PaymentClientService.name);
|
||||
private readonly baseUrl = (
|
||||
process.env.PAYMENT_API_URL ?? "http://localhost:3003"
|
||||
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
|
||||
).replace(/\/$/, "");
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
28
apps/edr-freight-api/src/scripts/seed-freight-demo.ts
Normal file
28
apps/edr-freight-api/src/scripts/seed-freight-demo.ts
Normal 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);
|
||||
});
|
||||
180
apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
Normal file
180
apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
Normal 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@)');
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import {
|
||||
AUTH_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
@@ -16,7 +17,7 @@ type RetriableRequest = {
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`,
|
||||
baseURL: `${API_BASE_URL}/api`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -122,6 +122,7 @@ export interface BookingFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface BookingDetailView {
|
||||
|
||||
@@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard";
|
||||
export * from "./BookingMileServicesCard";
|
||||
export * from "./BookingCargoCard";
|
||||
export * from "./BookingContractSummaryCard";
|
||||
export * from "./BookingCompanyCard";
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { API_BASE_URL } from '@/constants/apiConfig';
|
||||
|
||||
interface Cargo {
|
||||
id: string;
|
||||
@@ -32,8 +33,6 @@ interface CargoFormDialogProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function CargoFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
23
apps/edr-freight-web/backoffice/src/hooks/usePayments.ts
Normal file
23
apps/edr-freight-web/backoffice/src/hooks/usePayments.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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}.`
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import type { ViteDevServer, PreviewServer } from "vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function userManagementSpaFallback() {
|
||||
const rewrite = (req) => {
|
||||
const url = req.url || '';
|
||||
const rewrite = (req: IncomingMessage) => {
|
||||
const url = req.url ?? '';
|
||||
if (!url.startsWith('/_um/') && url !== '/_um') return;
|
||||
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
|
||||
req.url = '/_um/index.html';
|
||||
};
|
||||
return {
|
||||
name: 'user-management-spa-fallback',
|
||||
configureServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
|
||||
configurePreviewServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
|
||||
configureServer(s: ViteDevServer) {
|
||||
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||
rewrite(req);
|
||||
next();
|
||||
});
|
||||
|
||||
},
|
||||
configurePreviewServer(s: PreviewServer) {
|
||||
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||
rewrite(req);
|
||||
next();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +42,11 @@ export default defineConfig({
|
||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||
},
|
||||
// Force a single copy of these singletons so MantineProvider context is
|
||||
// shared between the backoffice app and @edr/ui-common (which ships its
|
||||
// own node_modules copy). Without this, two separate @mantine/core
|
||||
// instances are bundled and the context lookup fails at runtime.
|
||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
server: {
|
||||
port: 5183,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
3
apps/edr-freight-web/portal/src/constants/apiConfig.ts
Normal file
3
apps/edr-freight-web/portal/src/constants/apiConfig.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
30
apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts
Normal file
30
apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts
Normal 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"),
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
import type { Currency } from "@/pages/billing/invoices.mock";
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
FreightVolumeSection,
|
||||
HelloSection,
|
||||
InvoicesSection,
|
||||
RecentActivitySection,
|
||||
SetupPrompt,
|
||||
ShipmentsSection,
|
||||
StatsSection,
|
||||
} from "./components";
|
||||
import { useMyPortalData } from "./hooks";
|
||||
|
||||
export default function MyPortalPage() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
customer,
|
||||
bookingsQuery,
|
||||
dashboardQuery,
|
||||
allBookings,
|
||||
activeBookings,
|
||||
newActiveThisWeek,
|
||||
outstandingInvoices,
|
||||
totalOutstanding,
|
||||
companyName,
|
||||
greeting,
|
||||
recentInvoices,
|
||||
dashboard,
|
||||
volumePoints,
|
||||
maxVolume,
|
||||
} = useMyPortalData();
|
||||
|
||||
const handleBookingClick = (id: string) => {
|
||||
navigate(`/bookings/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
<HelloSection greeting={greeting} companyName={companyName} />
|
||||
|
||||
<SetupPrompt show={!customer} />
|
||||
|
||||
<StatsSection
|
||||
activeBookingsLength={activeBookings.length}
|
||||
newActiveThisWeek={newActiveThisWeek}
|
||||
bookingsLoading={bookingsQuery.isPending}
|
||||
outstandingInvoicesLength={outstandingInvoices.length}
|
||||
totalOutstanding={totalOutstanding}
|
||||
deliveredCount={dashboard?.deliveredCount.toString()}
|
||||
completionRate={dashboard?.completionRate?.toString()}
|
||||
spendYtd={
|
||||
dashboard
|
||||
? formatCurrency(
|
||||
dashboard.spendYtd,
|
||||
dashboard.spendCurrency as Currency,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
spendYtdChangePct={dashboard?.spendYtdChangePct}
|
||||
dashboardLoading={dashboardQuery.isPending}
|
||||
/>
|
||||
|
||||
<Grid align="stretch">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ShipmentsSection
|
||||
bookings={allBookings}
|
||||
isLoading={bookingsQuery.isPending}
|
||||
onBookingClick={handleBookingClick}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<InvoicesSection invoices={recentInvoices} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid align="stretch">
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<FreightVolumeSection
|
||||
totalTonnes={dashboard?.freightVolume.totalTonnes ?? 0}
|
||||
totalValue={dashboard?.freightVolume.totalValue ?? 0}
|
||||
currency={
|
||||
(dashboard?.freightVolume.currency ?? "ETB") as Currency
|
||||
}
|
||||
ytdChangePct={dashboard?.freightVolume.ytdChangePct ?? 0}
|
||||
volumePoints={volumePoints}
|
||||
maxVolume={maxVolume}
|
||||
isLoading={dashboardQuery.isPending}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<RecentActivitySection
|
||||
bookings={allBookings}
|
||||
isLoading={bookingsQuery.isPending}
|
||||
onBookingClick={handleBookingClick}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { format } from "date-fns";
|
||||
import { memo } from "react";
|
||||
import { STATUS_CONFIG, cv } from "../constants";
|
||||
|
||||
interface ActivityRowProps {
|
||||
booking: any;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const ActivityRow = memo(function ActivityRow({
|
||||
booking,
|
||||
onClick,
|
||||
}: ActivityRowProps) {
|
||||
const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
|
||||
const Icon = cfg.icon;
|
||||
const verb =
|
||||
booking.status === "IN_TRANSIT"
|
||||
? "departed"
|
||||
: booking.status === "COMPLETED"
|
||||
? "delivered"
|
||||
: booking.status === "PENDING_APPROVAL"
|
||||
? "quote ready"
|
||||
: booking.status === "SUBMITTED"
|
||||
? "submitted for review"
|
||||
: "created";
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap={12}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
py={9}
|
||||
className="cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Box
|
||||
w={36}
|
||||
h={36}
|
||||
bg={cfg.tile}
|
||||
className="flex shrink-0 items-center justify-center rounded-[10px]"
|
||||
>
|
||||
<Icon size={17} color={cv(cfg.iconColor)} />
|
||||
</Box>
|
||||
<Box className="min-w-0 flex-1">
|
||||
<Text fz={13} fw={600} c="edr-text" truncate>
|
||||
Booking {booking.reference} {verb}
|
||||
</Text>
|
||||
<Text fz={11} c="edr-muted" truncate>
|
||||
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} →{" "}
|
||||
{booking.destinationYard?.label ??
|
||||
booking.destinationYard?.code ??
|
||||
"—"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fz={11} c="edr-muted" className="shrink-0">
|
||||
{format(new Date(booking.createdAt), "MMM d")}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
|
||||
import { Stepper } from "./Stepper";
|
||||
|
||||
interface BookingRowProps {
|
||||
booking: any;
|
||||
last: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const BookingRow = memo(function BookingRow({
|
||||
booking,
|
||||
last,
|
||||
onClick,
|
||||
}: BookingRowProps) {
|
||||
const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
|
||||
const Icon = cfg.icon;
|
||||
const AIcon = cfg.action.icon;
|
||||
const ap = ACTION_PROPS[cfg.action.kind];
|
||||
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
||||
const dest =
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
||||
const commodity =
|
||||
(typeof booking.cargoType === "string"
|
||||
? booking.cargoType
|
||||
: booking.cargoType?.name) ??
|
||||
booking.commodity ??
|
||||
"Freight";
|
||||
|
||||
return (
|
||||
<Box className={last ? undefined : "border-b border-edr-divider"}>
|
||||
<Group
|
||||
gap={16}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
py={14}
|
||||
px={4}
|
||||
className="cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Box
|
||||
w={46}
|
||||
h={46}
|
||||
bg={cfg.tile}
|
||||
className="flex shrink-0 items-center justify-center rounded-xl"
|
||||
>
|
||||
<Icon size={22} color={cv(cfg.iconColor)} />
|
||||
</Box>
|
||||
|
||||
<Box className="min-w-0 flex-1 lg:!flex-none lg:!w-[188px]">
|
||||
<Text fz={15} fw={700} c="edr-text" truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<Text fz={12} c="edr-muted" truncate>
|
||||
{commodity} · {origin} → {dest}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="hidden min-w-0 flex-1 pr-2 lg:block">
|
||||
<Text fz={12} fw={500} mb={8} c={cfg.iconColor} truncate>
|
||||
{cfg.hint}
|
||||
</Text>
|
||||
<Stepper stage={cfg.stage} color={cfg.step} />
|
||||
</Box>
|
||||
|
||||
<Stack gap={9} align="flex-end" className="shrink-0">
|
||||
<Group
|
||||
gap={6}
|
||||
align="center"
|
||||
px={11}
|
||||
py={5}
|
||||
bg={cfg.badgeBg}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Box w={6} h={6} bg={cfg.badgeDot} className="rounded-full" />
|
||||
<Text fz={11} fw={700} c={cfg.badgeText}>
|
||||
{cfg.badgeLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group
|
||||
gap={5}
|
||||
align="center"
|
||||
px={15}
|
||||
py={8}
|
||||
bg={ap.bg}
|
||||
bd={ap.bd}
|
||||
className="cursor-pointer rounded-[9px]"
|
||||
>
|
||||
<Text fz={13} fw={700} c={ap.c}>
|
||||
{cfg.action.label}
|
||||
</Text>
|
||||
{AIcon && (
|
||||
<AIcon
|
||||
size={15}
|
||||
color={ap.c === "white" ? "#fff" : cv("edr-text")}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
padding?: number;
|
||||
}
|
||||
|
||||
export function Card({ children, className = "", padding = 24 }: CardProps) {
|
||||
return (
|
||||
<Box
|
||||
p={padding}
|
||||
className={`rounded-[20px] border border-edr-border bg-edr-card ${className}`}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Box, Text } from "@mantine/core";
|
||||
|
||||
interface EmptyStateProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ message }: EmptyStateProps) {
|
||||
return (
|
||||
<Box
|
||||
py="xl"
|
||||
className="rounded-xl border border-dashed border-edr-border text-center"
|
||||
>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{message}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Box, Group, Skeleton, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import type { Currency } from "@/pages/billing/invoices.mock";
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import { formatPct } from "../constants";
|
||||
import { Card } from "./Card";
|
||||
|
||||
interface FreightVolumeSectionProps {
|
||||
totalTonnes: number;
|
||||
totalValue: number;
|
||||
currency: Currency;
|
||||
ytdChangePct: number;
|
||||
volumePoints: Array<{ month: string; tonnes: number }>;
|
||||
maxVolume: number;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const FreightVolumeSection = memo(function FreightVolumeSection({
|
||||
totalTonnes,
|
||||
totalValue,
|
||||
currency,
|
||||
ytdChangePct,
|
||||
volumePoints,
|
||||
maxVolume,
|
||||
isLoading,
|
||||
}: FreightVolumeSectionProps) {
|
||||
return (
|
||||
<Card className="h-full" padding={24}>
|
||||
<Text fz={17} fw={700} c="edr-text">
|
||||
Freight Volume
|
||||
</Text>
|
||||
<Group gap={10} align="baseline" mt={4} mb={22}>
|
||||
{isLoading ? (
|
||||
<Skeleton height={32} width={180} radius="sm" />
|
||||
) : (
|
||||
<>
|
||||
<Text fz={26} fw={800} c="edr-text">
|
||||
{totalTonnes.toLocaleString()} t
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted">
|
||||
{formatCurrency(totalValue, currency)}
|
||||
</Text>
|
||||
<Text fz={12} fw={700} c="edr-green.7">
|
||||
{formatPct(ytdChangePct)} YTD
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
{isLoading ? (
|
||||
<Skeleton height={110} radius="md" />
|
||||
) : volumePoints.length === 0 ? (
|
||||
<Box className="flex h-[110px] items-center">
|
||||
<Text fz={13} c="edr-muted">
|
||||
No freight volume yet.
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Group align="flex-end" gap={10} className="h-[110px]">
|
||||
{volumePoints.map((point, i) => {
|
||||
const isLast = i === volumePoints.length - 1;
|
||||
return (
|
||||
<Box
|
||||
key={point.month}
|
||||
className="flex flex-1 flex-col items-center gap-2"
|
||||
>
|
||||
<Box
|
||||
bg={isLast ? "edr-green" : "edr-soft"}
|
||||
bd={isLast ? undefined : "1px solid edr-border"}
|
||||
h={Math.round((point.tonnes / maxVolume) * 86)}
|
||||
className="w-full rounded-t-md"
|
||||
/>
|
||||
<Text fz={11} c="edr-muted">
|
||||
{point.month}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { ArrowRight, Truck } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cv } from "../constants";
|
||||
|
||||
interface HelloSectionProps {
|
||||
greeting: string;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export const HelloSection = memo(function HelloSection({
|
||||
greeting,
|
||||
companyName,
|
||||
}: HelloSectionProps) {
|
||||
return (
|
||||
<Group justify="space-between" align="center" gap="md">
|
||||
<Box>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{greeting}
|
||||
</Text>
|
||||
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
|
||||
{companyName} 👋
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Link to="/bookings/new">
|
||||
<Group
|
||||
gap={14}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
bg="edr-green"
|
||||
px={18}
|
||||
py={14}
|
||||
className="w-full md:w-60! rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
|
||||
>
|
||||
<Truck size={22} color="#fff" />
|
||||
|
||||
<Box className="min-w-0 flex-1">
|
||||
<Text fz={14} fw={700} c="white" lh={1.3}>
|
||||
Book a shipment
|
||||
</Text>
|
||||
</Box>
|
||||
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
|
||||
<ArrowRight size={18} color={cv("edr-green.7")} />
|
||||
</Box>
|
||||
</Group>
|
||||
</Link>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { format } from "date-fns";
|
||||
import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cv, INVOICE_BADGE } from "../constants";
|
||||
import { Card } from "./Card";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
|
||||
interface InvoicesSectionProps {
|
||||
invoices: Array<{
|
||||
id: number;
|
||||
number: string;
|
||||
bookingReference: string;
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
status: InvoiceStatus;
|
||||
dueDate: string;
|
||||
paidDate: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const InvoicesSection = memo(function InvoicesSection({
|
||||
invoices,
|
||||
}: InvoicesSectionProps) {
|
||||
const outstandingInvoices = invoices.filter(
|
||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||
);
|
||||
const totalOutstanding = outstandingInvoices.reduce(
|
||||
(sum, inv) => sum + inv.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="h-full" padding={24}>
|
||||
<Group justify="space-between" align="center" mb={16}>
|
||||
<Text fz={17} fw={700} c="edr-text">
|
||||
Invoices
|
||||
</Text>
|
||||
<Link to="/billing">
|
||||
<Group gap={3} align="center" className="no-underline">
|
||||
<Text fz={13} fw={600} c="edr-green.7">
|
||||
View all
|
||||
</Text>
|
||||
<ChevronRight size={15} color={cv("edr-green.7")} />
|
||||
</Group>
|
||||
</Link>
|
||||
</Group>
|
||||
|
||||
<Box mb={16} p={16} bg="edr-amber-soft" className="rounded-[14px]">
|
||||
<Text fz={12} fw={600} c="edr-amber-text">
|
||||
Outstanding balance
|
||||
</Text>
|
||||
<Text fz={24} fw={800} mt={4} c="edr-text">
|
||||
{formatCurrency(totalOutstanding || 377500, "ETB")}
|
||||
</Text>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
mt={8}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text fz={12} c="edr-amber-text">
|
||||
{outstandingInvoices.length || 2} invoices unpaid
|
||||
</Text>
|
||||
<Group
|
||||
gap={5}
|
||||
align="center"
|
||||
px={14}
|
||||
py={8}
|
||||
bg="edr-accent"
|
||||
className="cursor-pointer rounded-[9px]"
|
||||
>
|
||||
<Zap size={15} color="#fff" />
|
||||
<Text fz={13} fw={700} c="white">
|
||||
Pay all
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<EmptyState message="No invoices yet." />
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
{invoices.map((invoice, i) => {
|
||||
const badge = INVOICE_BADGE[invoice.status];
|
||||
const dueText =
|
||||
invoice.status === "Paid"
|
||||
? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}`
|
||||
: invoice.status === "Overdue"
|
||||
? "Overdue 3 days"
|
||||
: `Due ${invoice.dueDate}`;
|
||||
const DueIcon =
|
||||
invoice.status === "Paid" ? CheckCircle2 : Clock3;
|
||||
const dueIconColor =
|
||||
invoice.status === "Paid"
|
||||
? cv("edr-green.5")
|
||||
: cv("edr-muted");
|
||||
|
||||
return (
|
||||
<Box key={invoice.id}>
|
||||
{i > 0 && <Box h={1} bg="edr-divider" />}
|
||||
<Stack gap={8} py={10}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Box>
|
||||
<Text fz={13} fw={700} c="edr-text">
|
||||
{invoice.number}
|
||||
</Text>
|
||||
<Text fz={11} c="edr-muted">
|
||||
{invoice.bookingReference}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fz={14} fw={700} c="edr-text">
|
||||
{formatCurrency(invoice.amount, invoice.currency)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Group gap={5} align="center">
|
||||
<DueIcon size={13} color={dueIconColor} />
|
||||
<Text fz={12} c="edr-muted">
|
||||
{dueText}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
bg={badge.bg}
|
||||
px={10}
|
||||
py={4}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Text fz={11} fw={700} c={badge.text}>
|
||||
{badge.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cv } from "../constants";
|
||||
import { ActivityRow } from "./ActivityRow";
|
||||
import { Card } from "./Card";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
|
||||
interface RecentActivitySectionProps {
|
||||
bookings: any[];
|
||||
isLoading: boolean;
|
||||
onBookingClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export const RecentActivitySection = memo(function RecentActivitySection({
|
||||
bookings,
|
||||
isLoading,
|
||||
onBookingClick,
|
||||
}: RecentActivitySectionProps) {
|
||||
return (
|
||||
<Card className="h-full" padding={24}>
|
||||
<Group justify="space-between" align="center" mb={16}>
|
||||
<Text fz={17} fw={700} c="edr-text">
|
||||
Recent Activity
|
||||
</Text>
|
||||
<Link to="/bookings">
|
||||
<Group gap={3} align="center" className="no-underline">
|
||||
<Text fz={13} fw={600} c="edr-green.7">
|
||||
View all
|
||||
</Text>
|
||||
<ChevronRight size={15} color={cv("edr-green.7")} />
|
||||
</Group>
|
||||
</Link>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack gap={10}>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} height={44} radius="md" />
|
||||
))}
|
||||
</Stack>
|
||||
) : bookings.length === 0 ? (
|
||||
<EmptyState message="No recent activity." />
|
||||
) : (
|
||||
<Stack gap={2}>
|
||||
{bookings.slice(0, 6).map((booking) => (
|
||||
<ActivityRow
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
onClick={() => onBookingClick(booking.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, Truck, AlertTriangle } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import { cv } from "../constants";
|
||||
|
||||
const REQUIRED_FIELDS: (keyof ProfileResponse)[] = [
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyAddress",
|
||||
"fanNumber",
|
||||
"contactPersonName",
|
||||
"contactPersonPhone",
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
];
|
||||
|
||||
function isProfileIncomplete(profile?: ProfileResponse | null): boolean {
|
||||
if (!profile) return true;
|
||||
return REQUIRED_FIELDS.some((field) => !profile[field]);
|
||||
}
|
||||
|
||||
interface SetupPromptProps {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) {
|
||||
const profileQuery = useQuery(
|
||||
api.companies.getProfile.queryOptions({ retry: false }),
|
||||
);
|
||||
|
||||
const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data);
|
||||
|
||||
if (!show && !incomplete) return null;
|
||||
|
||||
return (
|
||||
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Box className="flex-1">
|
||||
<Group gap={6} align="center" mb={6}>
|
||||
{incomplete && <AlertTriangle size={16} color={cv("edr-orange")} />}
|
||||
<Text fz={15} fw={700} c="edr-text">
|
||||
{incomplete ? "Complete Your Profile" : "Setup your Company Profile"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={13} c="edr-muted" mb={12}>
|
||||
{incomplete
|
||||
? "Your company profile is incomplete. Fill in the missing details to unlock all features."
|
||||
: "Complete your company information to unlock all features and start booking shipments."}
|
||||
</Text>
|
||||
<Link to="/settings" className="no-underline">
|
||||
<Group gap={8} align="center" className="w-fit">
|
||||
<Text fz={13} fw={600} c="edr-green.7">
|
||||
{incomplete ? "Complete Profile" : "Complete Setup"}
|
||||
</Text>
|
||||
<ArrowRight size={16} color={cv("edr-green.7")} />
|
||||
</Group>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box className="hidden shrink-0 sm:block">
|
||||
<Truck size={48} color={cv("edr-blue")} opacity={0.3} />
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Box, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import { BookingRow } from "./BookingRow";
|
||||
import { Card } from "./Card";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
|
||||
interface ShipmentsSectionProps {
|
||||
bookings: any[];
|
||||
isLoading: boolean;
|
||||
onBookingClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ShipmentsSection = memo(function ShipmentsSection({
|
||||
bookings,
|
||||
isLoading,
|
||||
onBookingClick,
|
||||
}: ShipmentsSectionProps) {
|
||||
return (
|
||||
<Card className="h-full" padding={28}>
|
||||
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
|
||||
<Box>
|
||||
<Text fz={19} fw={800} c="edr-text">
|
||||
My Shipments
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted">
|
||||
From draft to delivery — every booking in one place
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack gap={6}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} height={64} radius="md" />
|
||||
))}
|
||||
</Stack>
|
||||
) : bookings.length === 0 ? (
|
||||
<EmptyState message="No bookings in this view." />
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
{bookings.map((booking, i) => (
|
||||
<BookingRow
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
last={i === bookings.length - 1}
|
||||
onClick={() => onBookingClick(booking.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cv } from "../constants";
|
||||
|
||||
interface StatKpiProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
delta: string;
|
||||
deltaColor: string;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
export const StatKpi = memo(function StatKpi({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
deltaColor,
|
||||
divider,
|
||||
}: StatKpiProps) {
|
||||
return (
|
||||
<Box
|
||||
px={4}
|
||||
className={
|
||||
divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined
|
||||
}
|
||||
>
|
||||
<Group gap={6} align="center" mb={7} wrap="nowrap">
|
||||
<Icon size={15} color={cv("edr-muted")} className="shrink-0" />
|
||||
<Text fz={12} fw={600} c="edr-muted" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={8} align="flex-end" wrap="nowrap">
|
||||
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>
|
||||
{delta}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import { formatPct } from "../constants";
|
||||
import { Card } from "./Card";
|
||||
import { StatKpi } from "./StatKpi";
|
||||
|
||||
interface StatsSectionProps {
|
||||
activeBookingsLength: number;
|
||||
newActiveThisWeek: number;
|
||||
bookingsLoading: boolean;
|
||||
outstandingInvoicesLength: number;
|
||||
totalOutstanding: number;
|
||||
deliveredCount: string | undefined;
|
||||
completionRate: string | undefined;
|
||||
spendYtd: string | undefined;
|
||||
spendYtdChangePct: number | undefined;
|
||||
dashboardLoading: boolean;
|
||||
}
|
||||
|
||||
export const StatsSection = memo(function StatsSection({
|
||||
activeBookingsLength,
|
||||
newActiveThisWeek,
|
||||
bookingsLoading,
|
||||
outstandingInvoicesLength,
|
||||
totalOutstanding,
|
||||
deliveredCount,
|
||||
completionRate,
|
||||
spendYtd,
|
||||
spendYtdChangePct,
|
||||
}: StatsSectionProps) {
|
||||
return (
|
||||
<Card className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
|
||||
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>
|
||||
<StatKpi
|
||||
icon={Truck}
|
||||
label="Active Shipments"
|
||||
value={bookingsLoading ? "—" : activeBookingsLength.toString()}
|
||||
delta={bookingsLoading ? "" : `+${newActiveThisWeek} this week`}
|
||||
deltaColor="edr-green.7"
|
||||
/>
|
||||
<StatKpi
|
||||
icon={Clock3}
|
||||
label="Awaiting Payment"
|
||||
value={outstandingInvoicesLength.toString()}
|
||||
delta={`${formatCurrency(totalOutstanding || 0, "ETB")} due`}
|
||||
deltaColor="edr-amber-text"
|
||||
divider
|
||||
/>
|
||||
<StatKpi
|
||||
icon={CheckCircle2}
|
||||
label="Delivered (YTD)"
|
||||
value={deliveredCount ?? "—"}
|
||||
delta={completionRate ? `${completionRate}% completed` : ""}
|
||||
deltaColor="edr-muted"
|
||||
divider
|
||||
/>
|
||||
<StatKpi
|
||||
icon={Wallet}
|
||||
label="Spend YTD"
|
||||
value={spendYtd ?? "—"}
|
||||
delta={spendYtdChangePct ? `${formatPct(spendYtdChangePct)} YoY` : ""}
|
||||
deltaColor="edr-green.7"
|
||||
divider
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Box, Group } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
|
||||
interface StepperProps {
|
||||
stage: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const Stepper = memo(function Stepper({ stage, color }: StepperProps) {
|
||||
return (
|
||||
<Group gap={0} align="center" wrap="nowrap" className="h-3.5 w-full">
|
||||
{[0, 1, 2, 3, 4].map((i) => {
|
||||
const done = i < stage;
|
||||
const active = i === stage;
|
||||
const size = active ? 12 : done ? 9 : 8;
|
||||
return (
|
||||
<Group
|
||||
key={i}
|
||||
gap={0}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
className={i < 4 ? "flex-1" : undefined}
|
||||
>
|
||||
<Box
|
||||
w={size}
|
||||
h={size}
|
||||
bg={done || active ? color : "edr-step-idle"}
|
||||
className="shrink-0 rounded-full"
|
||||
/>
|
||||
{i < 4 && (
|
||||
<Box
|
||||
h={3}
|
||||
bg={i < stage ? color : "edr-conn-idle"}
|
||||
className="flex-1 rounded-full"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export { ActivityRow } from "./ActivityRow";
|
||||
export { BookingRow } from "./BookingRow";
|
||||
export { Card } from "./Card";
|
||||
export { EmptyState } from "./EmptyState";
|
||||
export { FreightVolumeSection } from "./FreightVolumeSection";
|
||||
export { HelloSection } from "./HelloSection";
|
||||
export { InvoicesSection } from "./InvoicesSection";
|
||||
export { RecentActivitySection } from "./RecentActivitySection";
|
||||
export { SetupPrompt } from "./SetupPrompt";
|
||||
export { ShipmentsSection } from "./ShipmentsSection";
|
||||
export { StatKpi } from "./StatKpi";
|
||||
export { StatsSection } from "./StatsSection";
|
||||
export { Stepper } from "./Stepper";
|
||||
340
apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
Normal file
340
apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
FileCheck2,
|
||||
FilePen,
|
||||
MapPin,
|
||||
Truck,
|
||||
Wallet,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||
|
||||
export const cv = (token: string) => {
|
||||
const [name, shade] = token.split(".");
|
||||
return `var(--mantine-color-${name}-${shade ?? "6"})`;
|
||||
};
|
||||
|
||||
export const formatPct = (n: number) => `${n >= 0 ? "+" : ""}${n}%`;
|
||||
|
||||
export const ACTIVE_STATUSES = [
|
||||
"DRAFT",
|
||||
"SUBMITTED",
|
||||
"PENDING_APPROVAL",
|
||||
"IN_TRANSIT",
|
||||
];
|
||||
|
||||
export interface StageConfig {
|
||||
stage: number;
|
||||
icon: LucideIcon;
|
||||
iconColor: string;
|
||||
tile: string;
|
||||
hint: string;
|
||||
step: string;
|
||||
badgeLabel: string;
|
||||
badgeBg: string;
|
||||
badgeText: string;
|
||||
badgeDot: string;
|
||||
action: {
|
||||
label: string;
|
||||
kind: "dark" | "amber" | "outline";
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
}
|
||||
|
||||
export const STATUS_CONFIG: Record<string, StageConfig> = {
|
||||
DRAFT: {
|
||||
stage: 0,
|
||||
icon: FilePen,
|
||||
iconColor: "edr-slate",
|
||||
tile: "edr-slate-soft",
|
||||
hint: "Draft saved · not yet submitted",
|
||||
step: "edr-step",
|
||||
badgeLabel: "Draft",
|
||||
badgeBg: "edr-slate-soft",
|
||||
badgeText: "edr-slate",
|
||||
badgeDot: "edr-step",
|
||||
action: { label: "Continue", kind: "dark" },
|
||||
},
|
||||
SUBMITTED: {
|
||||
stage: 1,
|
||||
icon: FileCheck2,
|
||||
iconColor: "edr-blue",
|
||||
tile: "edr-blue-soft",
|
||||
hint: "Quote being prepared by EDR",
|
||||
step: "edr-blue-dot",
|
||||
badgeLabel: "Reviewing",
|
||||
badgeBg: "edr-blue-soft",
|
||||
badgeText: "edr-blue",
|
||||
badgeDot: "edr-blue-dot",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
stage: 1,
|
||||
icon: FilePen,
|
||||
iconColor: "edr-amber-text",
|
||||
tile: "edr-amber-soft",
|
||||
hint: "Changes requested · please update",
|
||||
step: "edr-accent",
|
||||
badgeLabel: "Revise",
|
||||
badgeBg: "edr-amber-soft",
|
||||
badgeText: "edr-amber-text",
|
||||
badgeDot: "edr-accent",
|
||||
action: { label: "Update", kind: "dark" },
|
||||
},
|
||||
PENDING_APPROVAL: {
|
||||
stage: 2,
|
||||
icon: FileCheck2,
|
||||
iconColor: "edr-blue",
|
||||
tile: "edr-blue-soft",
|
||||
hint: "Pending internal approval",
|
||||
step: "edr-blue-dot",
|
||||
badgeLabel: "Pending",
|
||||
badgeBg: "edr-blue-soft",
|
||||
badgeText: "edr-blue",
|
||||
badgeDot: "edr-blue-dot",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
APPROVED_PENDING_SIGNATURE: {
|
||||
stage: 2,
|
||||
icon: FileCheck2,
|
||||
iconColor: "edr-blue",
|
||||
tile: "edr-blue-soft",
|
||||
hint: "Approved · awaiting signature",
|
||||
step: "edr-blue-dot",
|
||||
badgeLabel: "For Signature",
|
||||
badgeBg: "edr-blue-soft",
|
||||
badgeText: "edr-blue",
|
||||
badgeDot: "edr-blue-dot",
|
||||
action: { label: "Review", kind: "outline" },
|
||||
},
|
||||
APPROVED: {
|
||||
stage: 2,
|
||||
icon: CheckCircle2,
|
||||
iconColor: "edr-green.7",
|
||||
tile: "edr-soft",
|
||||
hint: "Quote approved · ready to sign",
|
||||
step: "edr-green.5",
|
||||
badgeLabel: "Approved",
|
||||
badgeBg: "edr-soft",
|
||||
badgeText: "edr-green.7",
|
||||
badgeDot: "edr-green.5",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
CONTRACT_READY: {
|
||||
stage: 2,
|
||||
icon: CheckCircle2,
|
||||
iconColor: "edr-green.7",
|
||||
tile: "edr-soft",
|
||||
hint: "Contract ready · awaiting signature",
|
||||
step: "edr-green.5",
|
||||
badgeLabel: "Contract Ready",
|
||||
badgeBg: "edr-soft",
|
||||
badgeText: "edr-green.7",
|
||||
badgeDot: "edr-green.5",
|
||||
action: { label: "Review", kind: "outline" },
|
||||
},
|
||||
SIGNED_CUSTOMER: {
|
||||
stage: 3,
|
||||
icon: CheckCircle2,
|
||||
iconColor: "edr-green.7",
|
||||
tile: "edr-soft",
|
||||
hint: "Signed by customer · internal processing",
|
||||
step: "edr-green.5",
|
||||
badgeLabel: "Signed",
|
||||
badgeBg: "edr-soft",
|
||||
badgeText: "edr-green.7",
|
||||
badgeDot: "edr-green.5",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
FULLY_EXECUTED: {
|
||||
stage: 3,
|
||||
icon: CheckCircle2,
|
||||
iconColor: "edr-green.7",
|
||||
tile: "edr-soft",
|
||||
hint: "Fully executed · generating PNR",
|
||||
step: "edr-green.5",
|
||||
badgeLabel: "Executed",
|
||||
badgeBg: "edr-soft",
|
||||
badgeText: "edr-green.7",
|
||||
badgeDot: "edr-green.5",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
PNR_GENERATED: {
|
||||
stage: 3,
|
||||
icon: FileCheck2,
|
||||
iconColor: "edr-blue",
|
||||
tile: "edr-blue-soft",
|
||||
hint: "PNR generated · awaiting payment verification",
|
||||
step: "edr-blue-dot",
|
||||
badgeLabel: "PNR Ready",
|
||||
badgeBg: "edr-blue-soft",
|
||||
badgeText: "edr-blue",
|
||||
badgeDot: "edr-blue-dot",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
PAYMENT_VERIFICATION_IN_PROGRESS: {
|
||||
stage: 2,
|
||||
icon: Clock3,
|
||||
iconColor: "edr-amber-text",
|
||||
tile: "edr-amber-soft",
|
||||
hint: "Verifying payment · please wait",
|
||||
step: "edr-accent",
|
||||
badgeLabel: "Verifying",
|
||||
badgeBg: "edr-amber-soft",
|
||||
badgeText: "edr-amber-text",
|
||||
badgeDot: "edr-accent",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
SELECTED_FOR_BATCH: {
|
||||
stage: 2,
|
||||
icon: Wallet,
|
||||
iconColor: "edr-amber-text",
|
||||
tile: "edr-amber-soft",
|
||||
hint: "Selected for batch · payment due within 1 hour",
|
||||
step: "edr-accent",
|
||||
badgeLabel: "Pay Now",
|
||||
badgeBg: "edr-amber-soft",
|
||||
badgeText: "edr-amber-text",
|
||||
badgeDot: "edr-accent",
|
||||
action: { label: "Pay now", kind: "amber", icon: ArrowRight },
|
||||
},
|
||||
EXPIRED: {
|
||||
stage: 1,
|
||||
icon: Clock3,
|
||||
iconColor: "edr-red",
|
||||
tile: "edr-red-soft",
|
||||
hint: "Payment window expired · contact support",
|
||||
step: "edr-red",
|
||||
badgeLabel: "Expired",
|
||||
badgeBg: "edr-red-soft",
|
||||
badgeText: "edr-red",
|
||||
badgeDot: "edr-red",
|
||||
action: { label: "Contact", kind: "outline" },
|
||||
},
|
||||
PAID: {
|
||||
stage: 3,
|
||||
icon: CheckCircle2,
|
||||
iconColor: "edr-green.7",
|
||||
tile: "edr-soft",
|
||||
hint: "Payment received · awaiting dispatch",
|
||||
step: "edr-green.5",
|
||||
badgeLabel: "Paid",
|
||||
badgeBg: "edr-soft",
|
||||
badgeText: "edr-green.7",
|
||||
badgeDot: "edr-green.5",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
stage: 3,
|
||||
icon: Truck,
|
||||
iconColor: "edr-green.7",
|
||||
tile: "edr-soft",
|
||||
hint: "In transit · on schedule",
|
||||
step: "edr-green.5",
|
||||
badgeLabel: "In Transit",
|
||||
badgeBg: "edr-soft",
|
||||
badgeText: "edr-green.7",
|
||||
badgeDot: "edr-green.5",
|
||||
action: { label: "Track", kind: "outline", icon: MapPin },
|
||||
},
|
||||
COMPLETED: {
|
||||
stage: 4,
|
||||
icon: CheckCircle2,
|
||||
iconColor: "edr-slate",
|
||||
tile: "edr-slate-soft2",
|
||||
hint: "Completed · awaiting delivery",
|
||||
step: "edr-green.5",
|
||||
badgeLabel: "Completed",
|
||||
badgeBg: "edr-slate-soft2",
|
||||
badgeText: "edr-slate",
|
||||
badgeDot: "edr-step",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
DELIVERED: {
|
||||
stage: 4,
|
||||
icon: CheckCircle2,
|
||||
iconColor: "edr-slate",
|
||||
tile: "edr-slate-soft2",
|
||||
hint: "Delivered · POD ready",
|
||||
step: "edr-green.5",
|
||||
badgeLabel: "Delivered",
|
||||
badgeBg: "edr-slate-soft2",
|
||||
badgeText: "edr-slate",
|
||||
badgeDot: "edr-step",
|
||||
action: { label: "View POD", kind: "outline" },
|
||||
},
|
||||
CANCELLED: {
|
||||
stage: 0,
|
||||
icon: FilePen,
|
||||
iconColor: "edr-red",
|
||||
tile: "edr-red-soft",
|
||||
hint: "Cancelled",
|
||||
step: "edr-red",
|
||||
badgeLabel: "Cancelled",
|
||||
badgeBg: "edr-red-soft",
|
||||
badgeText: "edr-red",
|
||||
badgeDot: "edr-red",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
REJECTED: {
|
||||
stage: 0,
|
||||
icon: FilePen,
|
||||
iconColor: "edr-red",
|
||||
tile: "edr-red-soft",
|
||||
hint: "Rejected · contact support",
|
||||
step: "edr-red",
|
||||
badgeLabel: "Rejected",
|
||||
badgeBg: "edr-red-soft",
|
||||
badgeText: "edr-red",
|
||||
badgeDot: "edr-red",
|
||||
action: { label: "Contact", kind: "outline" },
|
||||
},
|
||||
PENDING_CONSOLIDATION: {
|
||||
stage: 3,
|
||||
icon: Clock3,
|
||||
iconColor: "edr-blue",
|
||||
tile: "edr-blue-soft",
|
||||
hint: "Awaiting consolidation",
|
||||
step: "edr-blue-dot",
|
||||
badgeLabel: "Consolidating",
|
||||
badgeBg: "edr-blue-soft",
|
||||
badgeText: "edr-blue",
|
||||
badgeDot: "edr-blue-dot",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
CONSOLIDATED: {
|
||||
stage: 3,
|
||||
icon: CheckCircle2,
|
||||
iconColor: "edr-green.7",
|
||||
tile: "edr-soft",
|
||||
hint: "Consolidated · ready for dispatch",
|
||||
step: "edr-green.5",
|
||||
badgeLabel: "Consolidated",
|
||||
badgeBg: "edr-soft",
|
||||
badgeText: "edr-green.7",
|
||||
badgeDot: "edr-green.5",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
};
|
||||
|
||||
export const ACTION_PROPS: Record<
|
||||
string,
|
||||
{ bg: string; c: string; bd?: string }
|
||||
> = {
|
||||
dark: { bg: "edr-ink", c: "white" },
|
||||
amber: { bg: "edr-accent", c: "white" },
|
||||
outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" },
|
||||
};
|
||||
|
||||
export const INVOICE_BADGE: Record<
|
||||
InvoiceStatus,
|
||||
{ label: string; bg: string; text: string }
|
||||
> = {
|
||||
Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
|
||||
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
|
||||
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
|
||||
Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
|
||||
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
||||
};
|
||||
74
apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts
Normal file
74
apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { getMyInvoices } from "@/lib/currentCustomer";
|
||||
import { api } from "@/services/api";
|
||||
import { ACTIVE_STATUSES } from "./constants";
|
||||
|
||||
export function useMyPortalData() {
|
||||
const { user, customer } = useAuth();
|
||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||
|
||||
const bookingsQuery = useQuery(
|
||||
api.bookings.list.queryOptions({
|
||||
input: { sortBy: "createdAt", sortOrder: "DESC" },
|
||||
}),
|
||||
);
|
||||
|
||||
const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions());
|
||||
|
||||
const allBookings = bookingsQuery.data?.items ?? [];
|
||||
const activeBookings = allBookings.filter((b) =>
|
||||
ACTIVE_STATUSES.includes(b.status),
|
||||
);
|
||||
|
||||
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const newActiveThisWeek = activeBookings.filter(
|
||||
(b) => new Date(b.createdAt).getTime() >= weekAgo,
|
||||
).length;
|
||||
|
||||
const outstandingInvoices = myInvoices.filter(
|
||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||
);
|
||||
|
||||
const totalOutstanding = outstandingInvoices.reduce(
|
||||
(sum, inv) => sum + inv.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
|
||||
const companyName = (customer as any)?.companyName ?? displayName;
|
||||
|
||||
const hour = new Date().getHours();
|
||||
const greeting =
|
||||
hour < 12
|
||||
? "Good morning,"
|
||||
: hour < 18
|
||||
? "Good afternoon,"
|
||||
: "Good evening,";
|
||||
|
||||
const recentInvoices = myInvoices.slice(0, 3);
|
||||
|
||||
const dashboard = dashboardQuery.data;
|
||||
const volumePoints = dashboard?.freightVolume.monthly ?? [];
|
||||
const maxVolume = Math.max(1, ...volumePoints.map((p) => p.tonnes));
|
||||
|
||||
return {
|
||||
user,
|
||||
customer,
|
||||
bookingsQuery,
|
||||
dashboardQuery,
|
||||
allBookings,
|
||||
activeBookings,
|
||||
newActiveThisWeek,
|
||||
outstandingInvoices,
|
||||
totalOutstanding,
|
||||
companyName,
|
||||
greeting,
|
||||
recentInvoices,
|
||||
dashboard,
|
||||
volumePoints,
|
||||
maxVolume,
|
||||
myInvoices,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./MyPortalPage";
|
||||
19
apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx
Normal file
19
apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,51 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
Tabs,
|
||||
Card,
|
||||
TextInput,
|
||||
Button,
|
||||
Badge,
|
||||
Alert,
|
||||
Center,
|
||||
Loader,
|
||||
Grid,
|
||||
Tabs,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
Briefcase,
|
||||
CheckCircle2,
|
||||
Building2,
|
||||
FileCheck,
|
||||
Save,
|
||||
User,
|
||||
UserCheck,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { api } from "@/services/api";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import TabCompanyProfile from "./settings/TabCompanyProfile";
|
||||
import TabContactPerson from "./settings/TabContactPerson";
|
||||
import TabDocuments from "./settings/TabDocuments";
|
||||
import TabGeneralManager from "./settings/TabGeneralManager";
|
||||
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
|
||||
import TabDocuments from "./settings/TabDocuments";
|
||||
|
||||
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
|
||||
|
||||
function tabIncomplete(tabId: SettingsTab, profile?: ProfileResponse | null): boolean {
|
||||
if (!profile) return false;
|
||||
switch (tabId) {
|
||||
case "company":
|
||||
return !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || !profile.fanNumber;
|
||||
case "contact":
|
||||
return !profile.contactPersonName || !profile.contactPersonPhone;
|
||||
case "gm":
|
||||
return !profile.generalManagerName || !profile.generalManagerEmail || !profile.generalManagerPhone;
|
||||
case "poa":
|
||||
return false;
|
||||
case "documents":
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "company", label: "Company Profile", icon: <Building2 size={16} /> },
|
||||
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
|
||||
@@ -50,71 +55,68 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = (searchParams.get("tab") as SettingsTab) || "company";
|
||||
const setTab = (t: SettingsTab) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("tab", t);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
const setTab = useCallback(
|
||||
(t: SettingsTab) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("tab", t);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const profileQuery = useQuery(api.companies.getProfile.queryOptions());
|
||||
const profileQuery = useQuery(
|
||||
api.companies.getProfile.queryOptions({
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const profile = profileQuery.data;
|
||||
|
||||
const createCompanyMutation = useMutation({
|
||||
mutationFn: (payload: CreateCompanyPayload) =>
|
||||
api.companies.create.call(payload),
|
||||
onSuccess: () => {
|
||||
useEffect(() => {
|
||||
if (profileQuery.dataUpdatedAt > 0) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [profileQuery.dataUpdatedAt, queryClient]);
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z.string().min(1, "Company phone is required"),
|
||||
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
companyAddress: z.string().min(1, "Address is required"),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
});
|
||||
const [isOnboarding, setIsOnboarding] = useState<boolean | null>(null);
|
||||
|
||||
type OnboardingFormData = z.infer<typeof onboardingSchema>;
|
||||
useEffect(() => {
|
||||
if (profileQuery.isFetched && isOnboarding === null) {
|
||||
setIsOnboarding(!profileQuery.data);
|
||||
}
|
||||
}, [profileQuery.isFetched, profileQuery.data, isOnboarding]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<OnboardingFormData>({
|
||||
resolver: zodResolver(onboardingSchema),
|
||||
defaultValues: {
|
||||
companyPhoneCountryCode: "+251",
|
||||
},
|
||||
});
|
||||
const handleOnboardingSuccess = useCallback(() => {
|
||||
setTab("contact");
|
||||
}, [setTab]);
|
||||
|
||||
const onSubmitOnboarding = (data: OnboardingFormData) => {
|
||||
const payload: CreateCompanyPayload = {
|
||||
companyType: "customer",
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
tin: data.tinNumber,
|
||||
fanNumber: data.fanNumber,
|
||||
};
|
||||
createCompanyMutation.mutate(payload);
|
||||
};
|
||||
const handleContactContinue = useCallback(() => {
|
||||
setTab("gm");
|
||||
}, [setTab]);
|
||||
|
||||
const handleGMContinue = useCallback(() => {
|
||||
setTab("poa");
|
||||
}, [setTab]);
|
||||
|
||||
const handlePOAContinue = useCallback(() => {
|
||||
setTab("documents");
|
||||
}, [setTab]);
|
||||
|
||||
const handleDocumentsContinue = useCallback(() => {
|
||||
navigate("/portal");
|
||||
}, [navigate]);
|
||||
|
||||
if (profileQuery.isPending) {
|
||||
return (
|
||||
@@ -124,25 +126,54 @@ export default function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const onboarding = isOnboarding === true;
|
||||
|
||||
const renderProfileContent = (children: React.ReactNode) => {
|
||||
if (onboarding && tab !== "company" && !profile) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (!profile) {
|
||||
return (
|
||||
<Card padding="xl">
|
||||
<Center>
|
||||
<Alert
|
||||
icon={<AlertCircle size={24} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
>
|
||||
Please complete the company profile first.
|
||||
</Alert>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl">
|
||||
<Container size="xl" px="lg">
|
||||
<Group justify="space-between" mb="xl">
|
||||
<div>
|
||||
<Title order={1} size="h2">
|
||||
Account Settings
|
||||
{onboarding ? "Complete Your Profile" : "Account Settings"}
|
||||
</Title>
|
||||
<Text c="edr-muted" size="sm" mt={4}>
|
||||
Manage your company profile, personnel, and documents
|
||||
{onboarding
|
||||
? "Set up your company profile, personnel, and documents to get started"
|
||||
: "Manage your company profile, personnel, and documents"}
|
||||
</Text>
|
||||
</div>
|
||||
{profile && <Badge color="edr-green">Verified</Badge>}
|
||||
</Group>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
if (!profile && value !== "company") return;
|
||||
// if (onboarding) return;
|
||||
setTab(value as SettingsTab);
|
||||
}}
|
||||
>
|
||||
@@ -152,7 +183,12 @@ export default function SettingsPage() {
|
||||
key={t.id}
|
||||
value={t.id}
|
||||
leftSection={t.icon}
|
||||
disabled={!profile && t.id !== "company"}
|
||||
disabled={!onboarding && !profile && t.id !== "company"}
|
||||
rightSection={
|
||||
!onboarding && profile && tabIncomplete(t.id, profile) ? (
|
||||
<AlertCircle size={14} color="red" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
@@ -161,201 +197,52 @@ export default function SettingsPage() {
|
||||
|
||||
<Tabs.Panel value="company">
|
||||
{!profile ? (
|
||||
<Card padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Building2 size={20} />
|
||||
<Title order={3}>Company Profile</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm">
|
||||
Enter your company registration details to get started
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmitOnboarding)}>
|
||||
<Stack gap="md" mt="lg">
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Global Logistics Ltd"
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Company Email"
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||
phone={{
|
||||
...register("companyPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.companyPhoneCountryCode}
|
||||
phoneError={errors.companyPhone}
|
||||
label="Company Phone"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Address"
|
||||
placeholder="Bole Subcity, Woreda 03"
|
||||
error={errors.companyAddress?.message}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="TIN Number (10 digits)"
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
error={errors.tinNumber?.message}
|
||||
{...register("tinNumber")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="FAN Number (16 digits)"
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
error={errors.fanNumber?.message}
|
||||
{...register("fanNumber")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="xl"
|
||||
pt="md"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{createCompanyMutation.isSuccess && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Profile created successfully
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{createCompanyMutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Failed to create profile
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={createCompanyMutation.isPending}
|
||||
>
|
||||
Create Profile
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Card>
|
||||
<TabCompanyProfile
|
||||
mode="create"
|
||||
onCreateSuccess={handleOnboardingSuccess}
|
||||
/>
|
||||
) : (
|
||||
<TabCompanyProfile profile={profile} />
|
||||
<TabCompanyProfile mode="edit" profile={profile} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="contact">
|
||||
{profile ? (
|
||||
<TabContactPerson profile={profile} />
|
||||
) : (
|
||||
<Card padding="xl">
|
||||
<Center>
|
||||
<Alert
|
||||
icon={<AlertCircle size={24} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
>
|
||||
Please complete the company profile first.
|
||||
</Alert>
|
||||
</Center>
|
||||
</Card>
|
||||
{renderProfileContent(
|
||||
<TabContactPerson
|
||||
profile={profile!}
|
||||
mode={onboarding ? "onboarding" : "edit"}
|
||||
onContinue={handleContactContinue}
|
||||
/>,
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="gm">
|
||||
{profile ? (
|
||||
<TabGeneralManager profile={profile} />
|
||||
) : (
|
||||
<Card padding="xl">
|
||||
<Center>
|
||||
<Alert
|
||||
icon={<AlertCircle size={24} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
>
|
||||
Please complete the company profile first.
|
||||
</Alert>
|
||||
</Center>
|
||||
</Card>
|
||||
{renderProfileContent(
|
||||
<TabGeneralManager
|
||||
profile={profile!}
|
||||
mode={onboarding ? "onboarding" : "edit"}
|
||||
onContinue={handleGMContinue}
|
||||
/>,
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="poa">
|
||||
{profile ? (
|
||||
<TabPowerOfAttorney profile={profile} />
|
||||
) : (
|
||||
<Card padding="xl">
|
||||
<Center>
|
||||
<Alert
|
||||
icon={<AlertCircle size={24} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
>
|
||||
Please complete the company profile first.
|
||||
</Alert>
|
||||
</Center>
|
||||
</Card>
|
||||
{renderProfileContent(
|
||||
<TabPowerOfAttorney
|
||||
profile={profile!}
|
||||
mode={onboarding ? "onboarding" : "edit"}
|
||||
onContinue={handlePOAContinue}
|
||||
/>,
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="documents">
|
||||
{profile ? (
|
||||
<TabDocuments profile={profile} />
|
||||
) : (
|
||||
<Card padding="xl">
|
||||
<Center>
|
||||
<Alert
|
||||
icon={<AlertCircle size={24} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
>
|
||||
Please complete the company profile first.
|
||||
</Alert>
|
||||
</Center>
|
||||
</Card>
|
||||
{renderProfileContent(
|
||||
<TabDocuments
|
||||
profile={profile!}
|
||||
mode={onboarding ? "onboarding" : "edit"}
|
||||
onContinue={handleDocumentsContinue}
|
||||
/>,
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -14,7 +14,7 @@ export function StatusHero({
|
||||
booking: Freight.IBooking;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const status = booking.status as string;
|
||||
const status = booking.status;
|
||||
const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT;
|
||||
const negative = isNegative(status);
|
||||
const draft = isDraftLike(status);
|
||||
@@ -45,7 +45,12 @@ export function StatusHero({
|
||||
<Group gap={16} align="center" wrap="nowrap">
|
||||
<div
|
||||
className="flex items-center justify-center rounded-2xl shrink-0"
|
||||
style={{ width: 56, height: 56, backgroundColor: tileBg, color: tileFg }}
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
backgroundColor: tileBg,
|
||||
color: tileFg,
|
||||
}}
|
||||
>
|
||||
<HeroIcon size={26} />
|
||||
</div>
|
||||
@@ -77,7 +82,7 @@ export function StatusHero({
|
||||
>
|
||||
{chipLabel}
|
||||
</Text>
|
||||
<Text fz="13.5px" fw={700} c="#10202F">
|
||||
<Text fz="sm" fw={700} c="#10202F">
|
||||
{chipValue}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -100,7 +105,6 @@ export function StatusHero({
|
||||
function ProgressTracker({
|
||||
current,
|
||||
tone = "green",
|
||||
negative,
|
||||
}: {
|
||||
current: number;
|
||||
tone?: "green" | "ink";
|
||||
@@ -109,13 +113,17 @@ function ProgressTracker({
|
||||
const last = PROGRESS_STAGES.length - 1;
|
||||
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
|
||||
const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
|
||||
const activeSub = tone === "ink" ? "#475569" : "#0A6F4D";
|
||||
|
||||
return (
|
||||
/* Scrollable on mobile so 5 stages never overflow */
|
||||
<Box
|
||||
className="overflow-x-auto"
|
||||
style={{ scrollbarWidth: "none", WebkitOverflowScrolling: "touch" } as React.CSSProperties}
|
||||
className="overflow-x-auto pt-2"
|
||||
style={
|
||||
{
|
||||
scrollbarWidth: "none",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex items-start" style={{ minWidth: 440 }}>
|
||||
{PROGRESS_STAGES.map((stage, idx) => {
|
||||
@@ -131,14 +139,15 @@ function ProgressTracker({
|
||||
: state === "active"
|
||||
? activeFill
|
||||
: "#0EA371";
|
||||
const circleBorder = state === "idle" ? "1px solid #E1E7EE" : undefined;
|
||||
const circleBorder =
|
||||
state === "idle" ? "1px solid #E1E7EE" : undefined;
|
||||
const circleShadow =
|
||||
state === "active" ? `0 0 0 4px ${activeRing}` : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stage.label}
|
||||
className="flex flex-1 flex-col items-center gap-[10px]"
|
||||
className="flex flex-1 flex-col items-center"
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
{/* left connector */}
|
||||
@@ -147,15 +156,19 @@ function ProgressTracker({
|
||||
style={{
|
||||
height: 3,
|
||||
background:
|
||||
idx === 0 ? "transparent" : reachedLeft ? "#0EA371" : "#E1E7EE",
|
||||
idx === 0
|
||||
? "transparent"
|
||||
: reachedLeft
|
||||
? "#0EA371"
|
||||
: "#E1E7EE",
|
||||
}}
|
||||
/>
|
||||
{/* stage circle */}
|
||||
<div
|
||||
className="flex items-center justify-center rounded-full shrink-0"
|
||||
className="flex items-center justify-center mb-2 rounded-full shrink-0"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: circleBg,
|
||||
border: circleBorder,
|
||||
boxShadow: circleShadow,
|
||||
@@ -173,32 +186,22 @@ function ProgressTracker({
|
||||
style={{
|
||||
height: 3,
|
||||
background:
|
||||
idx === last ? "transparent" : reachedRight ? "#0EA371" : "#E1E7EE",
|
||||
idx === last
|
||||
? "transparent"
|
||||
: reachedRight
|
||||
? "#0EA371"
|
||||
: "#E1E7EE",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Text
|
||||
fz="13.5px"
|
||||
fw={state === "active" ? 800 : 700}
|
||||
fz="14px"
|
||||
fw={700}
|
||||
ta="center"
|
||||
c={state === "idle" ? "#9AA8B5" : "#10202F"}
|
||||
>
|
||||
{stage.label}
|
||||
</Text>
|
||||
<Text
|
||||
fz="11.5px"
|
||||
fw={state === "active" ? 700 : 500}
|
||||
ta="center"
|
||||
c={state === "active" ? activeSub : "#9AA8B5"}
|
||||
>
|
||||
{state === "done"
|
||||
? "Completed"
|
||||
: state === "active"
|
||||
? negative
|
||||
? "Stopped"
|
||||
: "In progress"
|
||||
: "Pending"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { CheckCircle2, Clock, FileText } from "lucide-react";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { CheckCircle2, Clock } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -173,16 +173,16 @@ export function PaymentCard({
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
fullWidth
|
||||
mt={16}
|
||||
variant="default"
|
||||
radius={10}
|
||||
leftSection={<FileText size={17} color="#475569" />}
|
||||
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }}
|
||||
>
|
||||
Download invoice
|
||||
</Button>
|
||||
{/* <Button */}
|
||||
{/* fullWidth */}
|
||||
{/* mt={16} */}
|
||||
{/* variant="default" */}
|
||||
{/* radius={10} */}
|
||||
{/* leftSection={<FileText size={17} color="#475569" />} */}
|
||||
{/* styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} */}
|
||||
{/* > */}
|
||||
{/* Download invoice */}
|
||||
{/* </Button> */}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
FileText,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -15,32 +16,41 @@ export const PROGRESS_STAGES = [
|
||||
{
|
||||
label: "Submitted",
|
||||
icon: ClipboardCheck,
|
||||
statuses: ["SUBMITTED", "PENDING_APPROVAL"],
|
||||
statuses: ["SUBMITTED"],
|
||||
},
|
||||
{
|
||||
label: "Approved",
|
||||
label: "Approval",
|
||||
icon: ShieldCheck,
|
||||
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", "APPROVED"],
|
||||
},
|
||||
{
|
||||
label: "Contract",
|
||||
icon: ShieldCheck,
|
||||
statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"],
|
||||
},
|
||||
{
|
||||
label: "Payment",
|
||||
icon: ShieldCheck,
|
||||
statuses: [
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
"APPROVED",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
"SELECTED_FOR_BATCH",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Loading",
|
||||
icon: Ship,
|
||||
statuses: [
|
||||
"PAID",
|
||||
"PNR_GENERATED",
|
||||
"PENDING_CONSOLIDATION",
|
||||
"CONSOLIDATED",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "In Transit",
|
||||
icon: Train,
|
||||
statuses: [
|
||||
"SELECTED_FOR_BATCH",
|
||||
"EXPIRED",
|
||||
"PNR_GENERATED",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"PENDING_CONSOLIDATION",
|
||||
"CONSOLIDATED",
|
||||
],
|
||||
statuses: ["EXPIRED", "IN_TRANSIT"],
|
||||
},
|
||||
{
|
||||
label: "Complete",
|
||||
@@ -72,7 +82,7 @@ export const STATUS_MAP: Record<
|
||||
PENDING_APPROVAL: {
|
||||
title: "Pending approval",
|
||||
description: "Your booking is moving through the approval process.",
|
||||
stage: 1,
|
||||
stage: 2,
|
||||
},
|
||||
APPROVED_PENDING_SIGNATURE: {
|
||||
title: "Approved — awaiting signature",
|
||||
@@ -88,71 +98,71 @@ export const STATUS_MAP: Record<
|
||||
title: "Contract ready to sign",
|
||||
description:
|
||||
"Your contract is ready. Review and apply your signature to proceed.",
|
||||
stage: 2,
|
||||
stage: 3,
|
||||
},
|
||||
SIGNED_CUSTOMER: {
|
||||
title: "Signed — awaiting staff",
|
||||
description:
|
||||
"Your signature has been submitted. Awaiting the final staff signature.",
|
||||
stage: 2,
|
||||
stage: 3,
|
||||
},
|
||||
FULLY_EXECUTED: {
|
||||
title: "Contract fully executed",
|
||||
description: "Signed by all parties. You can now proceed to payment.",
|
||||
stage: 2,
|
||||
stage: 4,
|
||||
},
|
||||
SELECTED_FOR_BATCH: {
|
||||
title: "Selected for a train — payment due",
|
||||
description:
|
||||
"Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.",
|
||||
stage: 3,
|
||||
stage: 4,
|
||||
},
|
||||
EXPIRED: {
|
||||
title: "Pay window expired",
|
||||
description:
|
||||
"The payment window was missed. You can move this booking to another schedule or cancel it.",
|
||||
stage: 3,
|
||||
stage: 6,
|
||||
},
|
||||
PNR_GENERATED: {
|
||||
title: "Payment reference generated",
|
||||
description:
|
||||
"A payment reference number has been generated for this booking.",
|
||||
stage: 3,
|
||||
stage: 5,
|
||||
},
|
||||
PAYMENT_VERIFICATION_IN_PROGRESS: {
|
||||
title: "Verifying payment",
|
||||
description: "Your payment is being verified.",
|
||||
stage: 3,
|
||||
stage: 4,
|
||||
},
|
||||
PAID: {
|
||||
title: "Payment confirmed",
|
||||
description: "Payment has been confirmed for this booking.",
|
||||
stage: 3,
|
||||
stage: 5,
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
title: "Cargo moving",
|
||||
description: "Your shipment is currently moving through the rail network.",
|
||||
stage: 3,
|
||||
stage: 6,
|
||||
},
|
||||
PENDING_CONSOLIDATION: {
|
||||
title: "Pending consolidation",
|
||||
description: "Awaiting a consolidation partner shipment.",
|
||||
stage: 3,
|
||||
stage: 5,
|
||||
},
|
||||
CONSOLIDATED: {
|
||||
title: "Consolidated",
|
||||
description: "Cargo has been consolidated with a partner shipment.",
|
||||
stage: 3,
|
||||
stage: 5,
|
||||
},
|
||||
COMPLETED: {
|
||||
title: "Service complete",
|
||||
description: "Cargo delivered and service successfully terminated.",
|
||||
stage: 4,
|
||||
stage: 7,
|
||||
},
|
||||
DELIVERED: {
|
||||
title: "Service complete",
|
||||
description: "Cargo delivered and service successfully terminated.",
|
||||
stage: 4,
|
||||
stage: 7,
|
||||
},
|
||||
REJECTED: {
|
||||
title: "Booking rejected",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@@ -34,12 +34,17 @@ import {
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { api } from "@/services/api";
|
||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
CountChip,
|
||||
DocRow,
|
||||
IconSquare,
|
||||
} from "./BookingDetailPage/components/Documents";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
BOOKING_DOCS_SETTING,
|
||||
BookingFormInputValues,
|
||||
bookingFormSchema,
|
||||
getRouteDirection,
|
||||
initialBookingFormValues,
|
||||
@@ -48,11 +53,6 @@ import {
|
||||
} from "./new-booking-form/schema";
|
||||
import { SelectField } from "./new-booking-form/shared";
|
||||
import { Step5CargoDetails } from "./new-booking-form/steps";
|
||||
import {
|
||||
CountChip,
|
||||
DocRow,
|
||||
IconSquare,
|
||||
} from "./BookingDetailPage/components/Documents";
|
||||
|
||||
function yardNameFromBooking(
|
||||
yard: { label?: string; code?: string; name?: string } | undefined | null,
|
||||
@@ -117,8 +117,6 @@ function mapBookingToFormValues(
|
||||
shippingLine: (booking as any).shippingLine?.name ?? "",
|
||||
consolidationEnabled: booking.allowConsolidation ?? false,
|
||||
notes: "",
|
||||
// Terms were accepted at creation; editing shouldn't re-gate on them.
|
||||
termsAccepted: true,
|
||||
containers: [],
|
||||
} as BookingFormInputValues;
|
||||
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||
import type {
|
||||
CreateBookingPayload,
|
||||
GeneratePriceResponse,
|
||||
} from "@/services/bookings.service";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Alert, Box, Button, Group, Text, Title } from "@mantine/core";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Send,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -97,6 +117,58 @@ export default function NewBookingPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const createAndPriceMutation = useMutation({
|
||||
mutationFn: async (payload: CreateBookingPayload) => {
|
||||
const booking = await api.bookings.create.call(payload);
|
||||
|
||||
const documents = form.getValues("documents") ?? {};
|
||||
const hasDocs = Object.values(documents).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||
);
|
||||
if (hasDocs) {
|
||||
await api.bookings.uploadDocuments.call({
|
||||
id: booking.id,
|
||||
files: documents,
|
||||
});
|
||||
}
|
||||
|
||||
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
|
||||
|
||||
return { bookingId: booking.id, pricing };
|
||||
},
|
||||
onSuccess: ({ bookingId, pricing }) => {
|
||||
setPriceBookingId(bookingId);
|
||||
setPricingData(pricing);
|
||||
setPricingPhase("ready");
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
},
|
||||
onError: () => {
|
||||
setPricingPhase("idle");
|
||||
},
|
||||
});
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!priceBookingId) throw new Error("No booking to confirm");
|
||||
await api.bookings.submit.call({ id: priceBookingId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
navigate(`/bookings/${priceBookingId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const abortMutation = useMutation({
|
||||
mutationFn: async (reason: string) => {
|
||||
if (!priceBookingId) throw new Error("No booking to abort");
|
||||
await api.bookings.cancel.call({ id: priceBookingId, reason });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setCancelDialogOpen(false);
|
||||
navigate("/bookings");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<BookingFormInputValues, any, BookingFormValues>({
|
||||
defaultValues: initialBookingFormValues,
|
||||
resolver: zodResolver(bookingFormSchema),
|
||||
@@ -116,6 +188,25 @@ export default function NewBookingPage() {
|
||||
return route;
|
||||
}, [originYard, destinationYard]);
|
||||
|
||||
const docValues = form.watch("documents") ?? {};
|
||||
const hasDocuments = useMemo(
|
||||
() =>
|
||||
Object.values(docValues).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||
),
|
||||
[docValues],
|
||||
);
|
||||
|
||||
const [pricingPhase, setPricingPhase] = useState<
|
||||
"idle" | "generating" | "ready"
|
||||
>("idle");
|
||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
|
||||
async function handleContinue() {
|
||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||
if (!valid) return;
|
||||
@@ -123,14 +214,14 @@ export default function NewBookingPage() {
|
||||
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
|
||||
}
|
||||
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
|
||||
if (data.contractType === "renewal" && !data.previousContractRef) {
|
||||
form.setError("previousContractRef", {
|
||||
type: "manual",
|
||||
message: "Select a previous contract reference.",
|
||||
});
|
||||
setStep(1);
|
||||
return;
|
||||
throw new Error("Validation failed");
|
||||
}
|
||||
|
||||
const totalWeight =
|
||||
@@ -141,7 +232,6 @@ export default function NewBookingPage() {
|
||||
)
|
||||
: Number(data.cargoWeight || 0);
|
||||
|
||||
// ── Reference data lookups ──────────────────────────────────────────
|
||||
const shippingLines = referenceData?.shipping_line ?? [];
|
||||
const cargoTree = referenceData?.cargo_type ?? [];
|
||||
const containerGroups = referenceData?.containers ?? [];
|
||||
@@ -174,8 +264,7 @@ export default function NewBookingPage() {
|
||||
(s) => s.id === data.serviceTypeId,
|
||||
)!;
|
||||
|
||||
// ── Build API payload ───────────────────────────────────────────────
|
||||
const apiPayload: CreateBookingPayload = {
|
||||
return {
|
||||
scheduledDate: new Date().toISOString(),
|
||||
contractType:
|
||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||
@@ -222,8 +311,25 @@ export default function NewBookingPage() {
|
||||
: {}),
|
||||
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
createMutation.mutate(apiPayload);
|
||||
const handleDraftSubmit = form.handleSubmit((data) => {
|
||||
try {
|
||||
const apiPayload = buildApiPayload(data);
|
||||
createMutation.mutate(apiPayload);
|
||||
} catch {
|
||||
// validation error already handled
|
||||
}
|
||||
});
|
||||
|
||||
const handleGeneratePrice = form.handleSubmit((data) => {
|
||||
try {
|
||||
const apiPayload = buildApiPayload(data);
|
||||
setPricingPhase("generating");
|
||||
createAndPriceMutation.mutate(apiPayload);
|
||||
} catch {
|
||||
// validation error already handled
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -270,7 +376,7 @@ export default function NewBookingPage() {
|
||||
id="new-booking-form"
|
||||
className="flex flex-col"
|
||||
style={{ flex: 1 }}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={handleDraftSubmit}
|
||||
>
|
||||
<Box flex={1} p="24px">
|
||||
<Box mb="lg">
|
||||
@@ -295,6 +401,24 @@ export default function NewBookingPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{createAndPriceMutation.isError && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<AlertCircle size={16} />}
|
||||
radius="md"
|
||||
mb="lg"
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
Failed to generate price estimate
|
||||
</Text>
|
||||
<Text size="sm" mt={4} c="red.7">
|
||||
{createAndPriceMutation.error instanceof Error
|
||||
? createAndPriceMutation.error.message
|
||||
: "An unexpected error occurred. Please try again."}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<Step1ContractType form={form} referenceData={referenceData} />
|
||||
)}
|
||||
@@ -326,6 +450,17 @@ export default function NewBookingPage() {
|
||||
setStep={setStep}
|
||||
direction={direction!}
|
||||
referenceData={referenceData}
|
||||
pricingPhase={pricingPhase}
|
||||
pricingData={pricingData}
|
||||
onConfirm={() => confirmMutation.mutate()}
|
||||
onContinueLater={
|
||||
priceBookingId
|
||||
? () => navigate(`/bookings/${priceBookingId}`)
|
||||
: undefined
|
||||
}
|
||||
onAbort={() => setCancelDialogOpen(true)}
|
||||
confirmPending={confirmMutation.isPending}
|
||||
abortPending={abortMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -366,24 +501,98 @@ export default function NewBookingPage() {
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-booking-form"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={createMutation.isPending}
|
||||
leftSection={
|
||||
createMutation.isPending ? undefined : <Check size={16} />
|
||||
}
|
||||
>
|
||||
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
|
||||
) : pricingPhase === "idle" ? (
|
||||
<Group>
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-booking-form"
|
||||
variant={hasDocuments ? "outline" : "filled"}
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={createMutation.isPending}
|
||||
leftSection={
|
||||
createMutation.isPending ? undefined : <Check size={16} />
|
||||
}
|
||||
>
|
||||
{createMutation.isPending
|
||||
? "Saving Draft..."
|
||||
: "Save as Draft"}
|
||||
</Button>
|
||||
{hasDocuments && (
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={createAndPriceMutation.isPending}
|
||||
leftSection={
|
||||
createAndPriceMutation.isPending ? undefined : (
|
||||
<Send size={16} />
|
||||
)
|
||||
}
|
||||
onClick={() => handleGeneratePrice()}
|
||||
>
|
||||
{createAndPriceMutation.isPending
|
||||
? "Generating price…"
|
||||
: "Submit"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
) : pricingPhase === "generating" ? (
|
||||
<Button type="button" color="edr-green" radius="md" loading>
|
||||
Generating price estimate…
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
{/* <DevTool control={form.control} /> */}
|
||||
|
||||
<Modal
|
||||
opened={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
title={<Text fw={700}>Abort booking</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Are you sure you want to abort this booking? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Reason for cancellation (optional)"
|
||||
placeholder="e.g. Change of plans…"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||
radius="md"
|
||||
data-autofocus
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setCancelDialogOpen(false)}
|
||||
>
|
||||
Keep editing
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
abortMutation.mutate(
|
||||
cancelReason.trim() || "Aborted by customer",
|
||||
)
|
||||
}
|
||||
disabled={abortMutation.isPending}
|
||||
loading={abortMutation.isPending}
|
||||
leftSection={
|
||||
!abortMutation.isPending ? <XCircle size={15} /> : undefined
|
||||
}
|
||||
>
|
||||
Yes, abort
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ export const bookingFormSchema = z
|
||||
consolidationEnabled: z.boolean(),
|
||||
documents: z.record(z.string(), z.any()).default({}),
|
||||
notes: z.string(),
|
||||
termsAccepted: z.boolean(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
@@ -165,23 +164,13 @@ export const bookingFormSchema = z
|
||||
(data) => !(data.cargoType === "container" && data.containers.length === 0),
|
||||
{ message: "Add at least one container.", path: ["containers"] },
|
||||
)
|
||||
.refine((data) => data.termsAccepted, {
|
||||
message: "Accept the freight contract terms to submit.",
|
||||
path: ["termsAccepted"],
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.cargoType === "bulk") {
|
||||
if (!data.cargoTypePath[0]) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoTypePath"],
|
||||
message: "Select a freight type.",
|
||||
});
|
||||
} else if (!data.cargoTypePath[1]) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoTypePath"],
|
||||
message: "Select a commodity.",
|
||||
message: "Select a Cargo type.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -237,7 +226,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||
consolidationEnabled: false,
|
||||
documents: {},
|
||||
notes: "",
|
||||
termsAccepted: false,
|
||||
};
|
||||
|
||||
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
@@ -265,7 +253,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
],
|
||||
5: ["scheduledDate", "trainScheduleId"],
|
||||
6: ["documents"],
|
||||
7: ["notes", "termsAccepted"],
|
||||
7: ["notes"],
|
||||
};
|
||||
|
||||
export interface ContainerConfig {
|
||||
@@ -288,10 +276,10 @@ export function getRouteDirection(
|
||||
return "DOMESTIC";
|
||||
}
|
||||
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
|
||||
return "IMPORT";
|
||||
return "EXPORT";
|
||||
}
|
||||
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
|
||||
return "EXPORT";
|
||||
return "IMPORT";
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
useMantineTheme
|
||||
} from "@mantine/core";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { BookingFormInputValues, BookingFormValues } from "./schema";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
addMonths,
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from "date-fns";
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Check,
|
||||
Train,
|
||||
Route,
|
||||
Package,
|
||||
Calendar as CalendarIcon,
|
||||
Route,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
isToday,
|
||||
isSameMonth,
|
||||
addMonths,
|
||||
} from "date-fns";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { BookingFormInputValues, BookingFormValues } from "./schema";
|
||||
|
||||
interface StepSchedulingProps {
|
||||
form: UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
||||
@@ -52,6 +53,7 @@ interface DayData {
|
||||
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
const theme = useMantineTheme();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
|
||||
|
||||
const selectedDate = form.watch("scheduledDate");
|
||||
const selectedScheduleId = form.watch("trainScheduleId");
|
||||
@@ -148,10 +150,35 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
|
||||
const weeksCount = Math.ceil(days.length / 7);
|
||||
|
||||
const handleDayClick = (day: DayData) => {
|
||||
if (day.schedules.length > 1) {
|
||||
setSelectedDayForModal(day);
|
||||
} else if (day.schedules.length === 1) {
|
||||
form.setValue("scheduledDate", day.dateString, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("trainScheduleId", day.schedules[0].id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectScheduleFromModal = (scheduleId: string) => {
|
||||
if (selectedDayForModal) {
|
||||
form.setValue("scheduledDate", selectedDayForModal.dateString, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("trainScheduleId", scheduleId, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
setSelectedDayForModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group gap={24} align="flex-start" wrap="nowrap">
|
||||
<Group gap={24} align="flex-start" wrap="wrap" style={{ width: "100%" }}>
|
||||
{/* ── Calendar Card ───────────────────────────────────── */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Box style={{ flex: 1, minWidth: 300 }}>
|
||||
<Card p={0} style={{ overflow: "hidden" }}>
|
||||
{/* Card header */}
|
||||
<Group
|
||||
@@ -244,15 +271,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
<DayCell
|
||||
key={di}
|
||||
day={d}
|
||||
selectedScheduleId={selectedScheduleId}
|
||||
onSelectSchedule={(scheduleId, dateString) => {
|
||||
form.setValue("scheduledDate", dateString, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("trainScheduleId", scheduleId, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -263,7 +282,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
</Box>
|
||||
|
||||
{/* ── Side Panel ──────────────────────────────────────── */}
|
||||
<Stack gap={20} w={340} style={{ flexShrink: 0 }}>
|
||||
<Stack gap={20} style={{ width: 340, flexShrink: 0 }}>
|
||||
{/* Booking summary card */}
|
||||
<Card p={0} style={{ overflow: "hidden" }}>
|
||||
<Group
|
||||
@@ -361,17 +380,81 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* ── Schedule Selection Modal ──────────────────────────── */}
|
||||
<Modal
|
||||
opened={!!selectedDayForModal}
|
||||
onClose={() => setSelectedDayForModal(null)}
|
||||
title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
|
||||
centered
|
||||
size="sm"
|
||||
styles={{
|
||||
header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` },
|
||||
body: { padding: 24 },
|
||||
}}
|
||||
>
|
||||
<Stack gap={12}>
|
||||
<Text fz={13} c="edr-muted" fw={500}>
|
||||
Choose a departure time
|
||||
</Text>
|
||||
{selectedDayForModal?.schedules.map((schedule) => (
|
||||
<Button
|
||||
key={schedule.id}
|
||||
variant="outline"
|
||||
fullWidth
|
||||
onClick={() => handleSelectScheduleFromModal(schedule.id)}
|
||||
style={{ height: 64, justifyContent: "flex-start" }}
|
||||
styles={{
|
||||
inner: { justifyContent: "flex-start" },
|
||||
root: {
|
||||
borderColor: theme.colors["edr-border"][0],
|
||||
transition: "all 150ms ease",
|
||||
"&:hover": {
|
||||
borderColor: theme.colors["edr-green"][5],
|
||||
backgroundColor: theme.colors["edr-soft"][0],
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group gap={16} w="100%">
|
||||
<Box
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: theme.radius.md,
|
||||
backgroundColor: theme.colors["edr-soft"][0],
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Train size={24} color={theme.colors["edr-green"][5]} />
|
||||
</Box>
|
||||
<Stack gap={3} style={{ flex: 1, alignItems: "flex-start" }}>
|
||||
<Text fw={700} fz={18} c="edr-text.0">
|
||||
{format(new Date(schedule.scheduleDate), "HH:mm")}
|
||||
</Text>
|
||||
{schedule.trainNumber && (
|
||||
<Text fz={12} c="edr-muted">
|
||||
Train {schedule.trainNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
interface DayCellProps {
|
||||
day: DayData;
|
||||
selectedScheduleId: string;
|
||||
onSelectSchedule: (scheduleId: string, dateString: string) => void;
|
||||
onDayClick: (day: DayData) => void;
|
||||
}
|
||||
|
||||
function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps) {
|
||||
function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
if (!d.isCurrentMonth) {
|
||||
@@ -400,125 +483,113 @@ function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps)
|
||||
: "transparent";
|
||||
|
||||
const cellBorder = d.isSelectedDate
|
||||
? `1.5px solid ${theme.colors["edr-green"][5]}`
|
||||
? `2px solid ${theme.colors["edr-green"][5]}`
|
||||
: d.hasSchedule
|
||||
? "1px solid #E7E8E5"
|
||||
? `1px solid ${theme.colors["edr-border"][0]}`
|
||||
: "none";
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => d.hasSchedule && onDayClick(d)}
|
||||
style={{
|
||||
height: 92,
|
||||
borderRadius: theme.radius.md,
|
||||
backgroundColor: cellBg,
|
||||
border: cellBorder,
|
||||
overflow: "hidden",
|
||||
padding: "4px 6px 6px",
|
||||
padding: "8px 10px 10px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
cursor: d.hasSchedule ? "pointer" : "default",
|
||||
transition: "all 150ms ease",
|
||||
boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (d.hasSchedule && !d.isSelectedDate) {
|
||||
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
|
||||
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (d.hasSchedule && !d.isSelectedDate) {
|
||||
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
|
||||
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Day number + check icon */}
|
||||
<Group justify="space-between" align="center" style={{ flexShrink: 0 }}>
|
||||
<Text
|
||||
fz={14}
|
||||
fw={d.hasSchedule ? 800 : 600}
|
||||
c={
|
||||
d.isToday && !d.isSelectedDate
|
||||
? "edr-green.6"
|
||||
: d.hasSchedule
|
||||
? "edr-text.0"
|
||||
: "edr-muted"
|
||||
}
|
||||
>
|
||||
{d.day}
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
<Text
|
||||
fz={16}
|
||||
fw={800}
|
||||
c={
|
||||
d.isToday && !d.isSelectedDate
|
||||
? "edr-green.6"
|
||||
: d.hasSchedule
|
||||
? "edr-text.0"
|
||||
: "edr-muted"
|
||||
}
|
||||
>
|
||||
{d.day}
|
||||
</Text>
|
||||
{d.isToday && !d.isSelectedDate && (
|
||||
<Text fz={9} fw={700} c="edr-green.6" style={{ letterSpacing: "0.04em" }}>
|
||||
TODAY
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{d.isSelectedDate && (
|
||||
<Check
|
||||
size={15}
|
||||
color={theme.colors["edr-green"][5]}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.colors["edr-green"][5],
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
size={14}
|
||||
color="white"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Departure chips */}
|
||||
{/* Schedule times */}
|
||||
{d.hasSchedule && (
|
||||
<Stack gap={3} style={{ flex: 1, overflow: "hidden" }}>
|
||||
{d.schedules.slice(0, 2).map((s) => {
|
||||
const isChipSelected = s.id === selectedScheduleId;
|
||||
const isFull = s.remainingWagons <= 0;
|
||||
const canSelect = !isFull;
|
||||
|
||||
return (
|
||||
<Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}>
|
||||
{d.schedules.slice(0, 2).map((s) => (
|
||||
<Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
key={s.id}
|
||||
onClick={() =>
|
||||
canSelect && onSelectSchedule(s.id, d.dateString)
|
||||
}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
borderRadius: 7,
|
||||
padding: "4px 6px",
|
||||
cursor: canSelect ? "pointer" : "default",
|
||||
backgroundColor: isChipSelected
|
||||
? theme.colors["edr-green"][5]
|
||||
: isFull
|
||||
? theme.colors["edr-red-soft"][0]
|
||||
: theme.colors["edr-soft"][0],
|
||||
border: `1px solid ${
|
||||
isChipSelected
|
||||
? theme.colors["edr-green"][5]
|
||||
: isFull
|
||||
? "#EFCFCA"
|
||||
: "#BFE3D4"
|
||||
}`,
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.colors["edr-green"][5],
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fz={12}
|
||||
fw={700}
|
||||
c="edr-text.0"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: isChipSelected
|
||||
? "white"
|
||||
: isFull
|
||||
? theme.colors["edr-red"][0]
|
||||
: theme.colors["edr-green"][5],
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
style={{ flex: 1, minWidth: 0, overflow: "hidden" }}
|
||||
truncate
|
||||
c={
|
||||
isChipSelected ? "white" : isFull ? "edr-red.0" : "edr-green.7"
|
||||
}
|
||||
>
|
||||
{isFull
|
||||
? "Full"
|
||||
: s.trainNumber
|
||||
? s.trainNumber
|
||||
: `${s.remainingWagons} wgn`}
|
||||
</Text>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
color={
|
||||
isChipSelected
|
||||
? "white"
|
||||
: isFull
|
||||
? theme.colors["edr-muted"][0]
|
||||
: theme.colors["edr-green"][7]
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{format(new Date(s.scheduleDate), "HH:mm")}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{d.schedules.length > 2 && (
|
||||
<Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}>
|
||||
+{d.schedules.length - 2} more
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
|
||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
type BookingFormValues,
|
||||
@@ -56,25 +56,24 @@ export function Step4Route({
|
||||
return true;
|
||||
});
|
||||
}, [yardOptions, destinationYard]);
|
||||
console.log({ yardOptions, originYard, destinationYard });
|
||||
const destData = useMemo(() => {
|
||||
return yardOptions.filter((o) => o.value !== originYard);
|
||||
}, [yardOptions, originYard]);
|
||||
|
||||
const direction = getRouteDirection(
|
||||
referenceData?.yard.find((y) => y.id === originYard),
|
||||
referenceData?.yard.find((y) => y.name === destinationYard),
|
||||
);
|
||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||
const direction = getRouteDirection(origin, dest);
|
||||
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
|
||||
|
||||
const directionStyle: Record<string, string> = {
|
||||
export: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
import: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
domestic: "bg-gray-100 text-gray-600 border-gray-200",
|
||||
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
DOMESTIC: "bg-gray-100 text-gray-600 border-gray-200",
|
||||
};
|
||||
const directionLabel: Record<string, string> = {
|
||||
export: "Export workflow (inside country to outside country)",
|
||||
import: "Import workflow (outside country to inside country)",
|
||||
domestic: "Domestic corridor",
|
||||
EXPORT: "Export workflow (inside country to outside country)",
|
||||
IMPORT: "Import workflow (outside country to inside country)",
|
||||
DOMESTIC: "Domestic corridor",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
BOOKING_DOCS_SETTING,
|
||||
@@ -8,6 +20,7 @@ import {
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import type { Freight } from "@/types";
|
||||
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -20,19 +33,32 @@ export function Step8Review({
|
||||
setStep,
|
||||
direction,
|
||||
referenceData,
|
||||
pricingPhase = "idle",
|
||||
pricingData,
|
||||
onConfirm,
|
||||
onContinueLater,
|
||||
onAbort,
|
||||
confirmPending = false,
|
||||
abortPending = false,
|
||||
}: {
|
||||
form: BookingForm;
|
||||
setStep: (step: number) => void;
|
||||
direction: Freight.ScheduleTradeDirection;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
pricingPhase?: "idle" | "generating" | "ready";
|
||||
pricingData?: GeneratePriceResponse | null;
|
||||
onConfirm?: () => void;
|
||||
onContinueLater?: () => void;
|
||||
onAbort?: () => void;
|
||||
confirmPending?: boolean;
|
||||
abortPending?: boolean;
|
||||
}) {
|
||||
const values = form.watch();
|
||||
const errors = form.formState.errors;
|
||||
const serviceType = referenceData?.service.find(
|
||||
(s) => s.id === values.serviceTypeId,
|
||||
);
|
||||
|
||||
function Row({
|
||||
function CompactRow({
|
||||
label,
|
||||
value,
|
||||
target,
|
||||
@@ -42,19 +68,19 @@ export function Step8Review({
|
||||
target: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<Text size="xs" c="dimmed">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} mt={2} className="truncate">
|
||||
<Text size="sm" fw={500} className="truncate">
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(target)}
|
||||
className="shrink-0 text-xs font-medium text-emerald-600 hover:underline"
|
||||
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -62,6 +88,28 @@ export function Step8Review({
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card radius="md" p="sm" withBorder className="border-gray-200 bg-white hover:shadow-sm transition-shadow">
|
||||
<Group gap="xs" mb="xs" wrap="nowrap">
|
||||
<Box c="edr-green">{Icon}</Box>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed" className="tracking-wider">
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">{children}</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const containerSummary =
|
||||
values.cargoType === "container" && values.containers.length > 0
|
||||
? values.containers
|
||||
@@ -95,146 +143,211 @@ export function Step8Review({
|
||||
return child ? `${group.name} — ${child.name}` : group.name;
|
||||
})();
|
||||
|
||||
function ReviewCard({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card radius="lg" withBorder p={0} className="overflow-hidden">
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
className="tracking-wider"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box px="md" py="xs" className="divide-y divide-gray-100">
|
||||
{children}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const originYardName = referenceData?.yard.find(
|
||||
(y) => y.id === values.originYard,
|
||||
)?.name ?? values.originYard;
|
||||
|
||||
const destinationYardName = referenceData?.yard.find(
|
||||
(y) => y.id === values.destinationYard,
|
||||
)?.name ?? values.destinationYard;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Stack gap="md">
|
||||
<StepHeader
|
||||
title="Review & Submit"
|
||||
description="Confirm your contract request before sending it for EDR staff review."
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
<ReviewCard title="Contract & Service">
|
||||
<Row
|
||||
{/* Pricing Card - Prominent at top */}
|
||||
{pricingPhase === "generating" && (
|
||||
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Generating price estimate…
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{pricingPhase === "ready" && pricingData && (
|
||||
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
|
||||
💳 Price Breakdown
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{pricingData.lineItems.map((item) => (
|
||||
<Group key={item.code} justify="space-between" py={2}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.amount.toLocaleString()} {item.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider my="xs" />
|
||||
<Group justify="space-between" py={2}>
|
||||
<Text fw={700} size="md">
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={800} size="lg" c="edr-green">
|
||||
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
{pricingData.warnings.length > 0 && (
|
||||
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
|
||||
⚠️ {pricingData.warnings.join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
<Group mt="md">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={onConfirm}
|
||||
loading={confirmPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{confirmPending ? "Confirming…" : "Confirm"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={16} />}
|
||||
onClick={onContinueLater}
|
||||
className="flex-1"
|
||||
>
|
||||
Continue later
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
|
||||
onClick={onAbort}
|
||||
loading={abortPending}
|
||||
>
|
||||
Abort
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Review Details - Compact Cards Grid */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="sm" mt="md">
|
||||
<CompactCard icon={<Package size={16} />} title="Contract & Service">
|
||||
<CompactRow
|
||||
label="Type"
|
||||
value={values.contractType === "new" ? "New Contract" : "Renewal"}
|
||||
target={1}
|
||||
/>
|
||||
<Row label="Service" value={serviceType?.name ?? ""} target={2} />
|
||||
</ReviewCard>
|
||||
<CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
|
||||
</CompactCard>
|
||||
|
||||
<ReviewCard title="First & Last Mile">
|
||||
<Row
|
||||
<CompactCard icon={<Route size={16} />} title="Route">
|
||||
<CompactRow
|
||||
label="Origin → Destination"
|
||||
value={`${originYardName} → ${destinationYardName}`}
|
||||
target={3}
|
||||
/>
|
||||
<CompactRow
|
||||
label="Workflow"
|
||||
value={
|
||||
direction ? direction.charAt(0).toUpperCase() + direction.slice(1) : ""
|
||||
}
|
||||
target={3}
|
||||
/>
|
||||
</CompactCard>
|
||||
|
||||
<CompactCard icon={<Truck size={16} />} title="Logistics">
|
||||
<CompactRow
|
||||
label="First Mile"
|
||||
value={
|
||||
values.firstMile.enabled
|
||||
? values.firstMile.pickUpAddress
|
||||
: "Not requested"
|
||||
values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
|
||||
}
|
||||
target={2}
|
||||
/>
|
||||
<Row
|
||||
<CompactRow
|
||||
label="Last Mile"
|
||||
value={
|
||||
values.lastMile.enabled
|
||||
? values.lastMile.deliveryAddress
|
||||
: "Not requested"
|
||||
values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
|
||||
}
|
||||
target={2}
|
||||
/>
|
||||
<Row
|
||||
<CompactRow
|
||||
label="Equipment Return"
|
||||
value={
|
||||
values.equipmentReturn === "with_return"
|
||||
? "With Return"
|
||||
: "Without Return"
|
||||
values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
|
||||
}
|
||||
target={2}
|
||||
/>
|
||||
<Row
|
||||
<CompactRow
|
||||
label="Customs Clearing"
|
||||
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
|
||||
target={2}
|
||||
/>
|
||||
</ReviewCard>
|
||||
</CompactCard>
|
||||
|
||||
<ReviewCard title="Route & Cargo">
|
||||
<Row
|
||||
label="Route"
|
||||
value={`${values.originYard} → ${values.destinationYard}`}
|
||||
target={3}
|
||||
/>
|
||||
<Row
|
||||
label="Workflow"
|
||||
value={
|
||||
direction
|
||||
? direction.charAt(0).toUpperCase() + direction.slice(1)
|
||||
: ""
|
||||
}
|
||||
target={3}
|
||||
/>
|
||||
<Row
|
||||
<CompactCard icon={<Package size={16} />} title="Cargo Details">
|
||||
<CompactRow
|
||||
label="Weight (VGM)"
|
||||
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
|
||||
target={4}
|
||||
/>
|
||||
<Row label="Cargo" value={cargoValue} target={4} />
|
||||
<Row
|
||||
<CompactRow label="Cargo Type" value={cargoValue} target={4} />
|
||||
<CompactRow
|
||||
label="Modifiers"
|
||||
value={
|
||||
[
|
||||
values.isHazardous && "Hazardous",
|
||||
values.isRefrigerated && "Refrigerated",
|
||||
]
|
||||
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "None"
|
||||
}
|
||||
target={3}
|
||||
/>
|
||||
</ReviewCard>
|
||||
</CompactCard>
|
||||
|
||||
<ReviewCard title="Container & Wagons">
|
||||
<Row label="Containers" value={containerSummary || "—"} target={4} />
|
||||
<Row
|
||||
label="Total VGM"
|
||||
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
|
||||
<CompactCard icon={<Package size={16} />} title="Containers">
|
||||
<CompactRow
|
||||
label="Count & Type"
|
||||
value={containerSummary || "—"}
|
||||
target={4}
|
||||
/>
|
||||
</ReviewCard>
|
||||
|
||||
<ReviewCard title="Documents">
|
||||
<Row
|
||||
label="Attached"
|
||||
value={
|
||||
docsAttached > 0
|
||||
? `${docsAttached} of ${docsTotal} attached`
|
||||
: "None — upload later from the booking page"
|
||||
}
|
||||
target={5}
|
||||
<CompactRow
|
||||
label="Total VGM"
|
||||
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
|
||||
target={4}
|
||||
/>
|
||||
</ReviewCard>
|
||||
</CompactCard>
|
||||
|
||||
<CompactCard icon={<FileText size={16} />} title="Documents">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
|
||||
Attached
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{docsAttached > 0
|
||||
? `${docsAttached} of ${docsTotal}`
|
||||
: "None"}
|
||||
</Text>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(5)}
|
||||
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</CompactCard>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Notes */}
|
||||
<Controller
|
||||
name="notes"
|
||||
control={form.control}
|
||||
@@ -243,35 +356,13 @@ export function Step8Review({
|
||||
{...field}
|
||||
id="notes"
|
||||
label="Additional Notes"
|
||||
placeholder="Any special instructions or notes for EDR operations..."
|
||||
rows={3}
|
||||
placeholder="Any special instructions or notes for EDR operations…"
|
||||
rows={2}
|
||||
radius="md"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="termsAccepted"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Checkbox
|
||||
label={
|
||||
<Text size="sm" c="dimmed">
|
||||
I confirm the information is accurate and agree to EDR's{" "}
|
||||
<Text component="span" c="edr-green" fw={500}>
|
||||
freight contract terms and conditions
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
}
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||
error={fieldState.error?.message ?? errors.termsAccepted?.message}
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
import { api } from "@/services/api";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
|
||||
const schema = z.object({
|
||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z.string().min(1, "Company phone is required"),
|
||||
@@ -29,29 +30,52 @@ const schema = z.object({
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
|
||||
|
||||
function splitPhone(fullPhone?: string | null) {
|
||||
export function splitPhone(fullPhone?: string | null) {
|
||||
if (!fullPhone) return { code: "+251", number: "" };
|
||||
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
|
||||
if (match) return { code: match[1], number: match[2] };
|
||||
return { code: "+251", number: fullPhone };
|
||||
}
|
||||
|
||||
export default function TabCompanyProfile({ profile }: { profile: ProfileResponse }) {
|
||||
const queryClient = useQueryClient();
|
||||
interface TabCompanyProfileProps {
|
||||
profile?: ProfileResponse;
|
||||
mode?: "edit" | "create";
|
||||
onCreateSuccess?: () => void;
|
||||
}
|
||||
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
const phone = splitPhone(profile.companyPhone);
|
||||
export default function TabCompanyProfile({
|
||||
profile,
|
||||
mode = "edit",
|
||||
onCreateSuccess,
|
||||
}: TabCompanyProfileProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isCreate = mode === "create";
|
||||
|
||||
const defaultValues = useMemo((): CompanyProfileFormData => {
|
||||
if (profile) {
|
||||
const phone = splitPhone(profile.companyPhone);
|
||||
return {
|
||||
companyName: profile.companyName,
|
||||
companyEmail: profile.companyEmail ?? "",
|
||||
companyPhone: phone.number,
|
||||
companyPhoneCountryCode: phone.code,
|
||||
companyLocation: profile.companyLocation,
|
||||
companyAddress: profile.companyAddress ?? "",
|
||||
tinNumber: profile.tinNumber,
|
||||
fanNumber: profile.fanNumber ?? "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
companyName: profile.companyName,
|
||||
companyEmail: profile.companyEmail ?? "",
|
||||
companyPhone: phone.number,
|
||||
companyPhoneCountryCode: phone.code,
|
||||
companyLocation: profile.companyLocation,
|
||||
companyAddress: profile.companyAddress ?? "",
|
||||
tinNumber: profile.tinNumber,
|
||||
fanNumber: profile.fanNumber ?? "",
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyPhoneCountryCode: "+251",
|
||||
companyLocation: "",
|
||||
companyAddress: "",
|
||||
tinNumber: "",
|
||||
fanNumber: "",
|
||||
};
|
||||
}, [profile]);
|
||||
|
||||
@@ -60,14 +84,15 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
} = useForm<CompanyProfileFormData>({
|
||||
resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
api.companies.updateProfile.call({
|
||||
mutationFn: async (data: CompanyProfileFormData) => {
|
||||
const payload: CreateCompanyPayload = {
|
||||
companyType: "customer",
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||
@@ -75,13 +100,25 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
|
||||
companyAddress: data.companyAddress,
|
||||
tin: data.tinNumber,
|
||||
fanNumber: data.fanNumber,
|
||||
}),
|
||||
};
|
||||
|
||||
if (isCreate) {
|
||||
return api.companies.create.call(payload);
|
||||
} else {
|
||||
return api.companies.updateProfile.call(payload);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
if (isCreate) {
|
||||
onCreateSuccess?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormData) => mutation.mutate(data);
|
||||
const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data);
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
@@ -90,7 +127,9 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
|
||||
<Title order={3}>Company Profile</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
Edit your company registration details
|
||||
{isCreate
|
||||
? "Enter your company registration details to get started"
|
||||
: "Edit your company registration details"}
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
@@ -115,7 +154,10 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
|
||||
<Grid.Col span={6}>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||
phone={{ ...register("companyPhone"), placeholder: "912345678" }}
|
||||
phone={{
|
||||
...register("companyPhone"),
|
||||
placeholder: "912345678",
|
||||
}}
|
||||
countryCodeError={errors.companyPhoneCountryCode}
|
||||
phoneError={errors.companyPhone}
|
||||
label="Company Phone"
|
||||
@@ -171,34 +213,40 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{mutation.isSuccess && (
|
||||
{mutation.isSuccess && !isCreate && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>Saved successfully</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
Saved successfully
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{mutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>Save failed</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{isCreate ? "Failed to create profile" : "Save failed"}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{!isCreate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Save Changes
|
||||
{isCreate ? "Continue" : "Save Changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -32,7 +32,13 @@ function splitPhone(fullPhone?: string | null) {
|
||||
return { code: "+251", number: fullPhone };
|
||||
}
|
||||
|
||||
export default function TabContactPerson({ profile }: { profile: ProfileResponse }) {
|
||||
interface TabContactPersonProps {
|
||||
profile: ProfileResponse;
|
||||
mode?: "edit" | "onboarding";
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
export default function TabContactPerson({ profile, mode = "edit", onContinue }: TabContactPersonProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
@@ -62,6 +68,7 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
||||
if (mode === "onboarding") onContinue?.();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,20 +123,22 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{mode === "edit" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Save Changes
|
||||
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
FileCheck,
|
||||
Loader2,
|
||||
@@ -20,7 +21,13 @@ import { companiesService } from "@/services/companies.service";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
export default function TabDocuments({ profile }: { profile: ProfileResponse }) {
|
||||
interface TabDocumentsProps {
|
||||
profile: ProfileResponse;
|
||||
mode?: "edit" | "onboarding";
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
|
||||
|
||||
@@ -75,7 +82,9 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
|
||||
{docUploadMutation.isSuccess && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>Documents uploaded successfully</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{docUploadMutation.isError && (
|
||||
@@ -85,14 +94,37 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
type="button"
|
||||
leftSection={<UploadCloud size={16} />}
|
||||
loading={docUploadMutation.isPending}
|
||||
onClick={() => docUploadMutation.mutate(documentFiles)}
|
||||
>
|
||||
Upload Documents
|
||||
</Button>
|
||||
{mode === "onboarding" ? (
|
||||
<Button
|
||||
type="button"
|
||||
leftSection={<ArrowRight size={16} />}
|
||||
loading={docUploadMutation.isPending}
|
||||
onClick={() => {
|
||||
const hasFiles = Object.values(documentFiles).some((f) => f !== null);
|
||||
if (hasFiles) {
|
||||
docUploadMutation.mutate(documentFiles, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
||||
onContinue?.();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
onContinue?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
leftSection={<UploadCloud size={16} />}
|
||||
loading={docUploadMutation.isPending}
|
||||
onClick={() => docUploadMutation.mutate(documentFiles)}
|
||||
>
|
||||
Upload Documents
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -34,7 +34,13 @@ function splitPhone(fullPhone?: string | null) {
|
||||
return { code: "+251", number: fullPhone };
|
||||
}
|
||||
|
||||
export default function TabGeneralManager({ profile }: { profile: ProfileResponse }) {
|
||||
interface TabGeneralManagerProps {
|
||||
profile: ProfileResponse;
|
||||
mode?: "edit" | "onboarding";
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
@@ -66,6 +72,7 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
||||
if (mode === "onboarding") onContinue?.();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -133,20 +140,22 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{mode === "edit" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Save Changes
|
||||
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -36,11 +36,17 @@ function splitPhone(fullPhone?: string | null) {
|
||||
return { code: "+251", number: fullPhone };
|
||||
}
|
||||
|
||||
interface TabPowerOfAttorneyProps {
|
||||
profile: ProfileResponse;
|
||||
mode?: "edit" | "onboarding";
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
export default function TabPowerOfAttorney({
|
||||
profile,
|
||||
}: {
|
||||
profile: ProfileResponse;
|
||||
}) {
|
||||
mode = "edit",
|
||||
onContinue,
|
||||
}: TabPowerOfAttorneyProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
@@ -49,7 +55,7 @@ export default function TabPowerOfAttorney({
|
||||
poaName: profile.poaName ?? "",
|
||||
poaEmail: profile.poaEmail ?? "",
|
||||
poaPhone: phone.number,
|
||||
poaPhoneCountryCode: profile.poaPhone ? phone.code : "",
|
||||
poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251",
|
||||
poaLocation: profile.poaLocation ?? "",
|
||||
poaAddress: profile.poaAddress ?? "",
|
||||
};
|
||||
@@ -81,6 +87,7 @@ export default function TabPowerOfAttorney({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
if (mode === "onboarding") onContinue?.();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -99,11 +106,6 @@ export default function TabPowerOfAttorney({
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Text c="edr-muted" size="sm">
|
||||
Power of Attorney details are optional. Fill them in if you have an
|
||||
authorized representative, or leave blank.
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="PoA Full Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
@@ -177,20 +179,22 @@ export default function TabPowerOfAttorney({
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{mode === "edit" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Save Changes
|
||||
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const P = URL_CONSTANTS.PAYMENTS;
|
||||
@@ -57,7 +58,7 @@ function buildCheckoutUrl(payload: {
|
||||
method: PaymentMethod;
|
||||
platform?: PaymentPlatform;
|
||||
}): string {
|
||||
const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||
const base = API_BASE_URL.replace(/\/$/, "");
|
||||
const params = new URLSearchParams({
|
||||
bookingId: payload.bookingId,
|
||||
method: payload.method,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
baseURL: API_BASE_URL,
|
||||
});
|
||||
|
||||
function getCookie(name: string): string | undefined {
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Pin Mantine to this app's copy. pnpm can install a second @mantine/core under
|
||||
// @edr/ui-common (linked to react@18) while the portal uses react@19 — dedupe
|
||||
// alone does not merge those into one module in production builds.
|
||||
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
|
||||
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||
"@mantine/core": mantineCore,
|
||||
"@mantine/hooks": mantineHooks,
|
||||
},
|
||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "returnDestinationStationId" TEXT,
|
||||
ADD COLUMN "returnHoldId" TEXT,
|
||||
ADD COLUMN "returnOriginStationId" TEXT,
|
||||
ADD COLUMN "returnScheduleId" TEXT,
|
||||
ADD COLUMN "returnSeatClassId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -682,20 +682,6 @@ async function main() {
|
||||
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
['system users', seedSystemUsers],
|
||||
['stations', seedStations],
|
||||
['coach types & classes', seedCoachTypesAndClasses],
|
||||
['route', seedRoute],
|
||||
['coaches', seedCoaches],
|
||||
['trips', seedTrips],
|
||||
['fare rules', seedFareRules],
|
||||
['currency', seedCurrency],
|
||||
['payment methods', seedPaymentMethods],
|
||||
['notification templates', seedNotificationTemplates],
|
||||
['menu & food', seedMenuAndFood],
|
||||
['promotions', seedPromotions],
|
||||
['FAQ', seedFAQ],
|
||||
['fraud rules', seedFraudRules],
|
||||
['segment fares', seedSegmentFares],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -6,9 +6,11 @@ import { ConfigService } from '@nestjs/config';
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
const secret = config.get<string>('JWT_SECRET');
|
||||
if (!secret) throw new Error('JWT_SECRET environment variable is not set');
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: config.get('JWT_SECRET'),
|
||||
secretOrKey: secret,
|
||||
});
|
||||
}
|
||||
async validate(payload: any) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||
import { DynamicModule } from "@nestjs/common";
|
||||
import {
|
||||
PAYMENT_EVENTS_DLX,
|
||||
PAYMENT_EVENTS_EXCHANGE,
|
||||
@@ -20,21 +21,15 @@ import { TicketsModule } from "../tickets/tickets.module";
|
||||
|
||||
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SeatsModule,
|
||||
TicketsModule,
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
function rabbitMQImport(): DynamicModule[] {
|
||||
if (!process.env.PAYMENT_RABBITMQ_URL) return [];
|
||||
return [
|
||||
RabbitMQModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
uri: config.get<string>("rabbitmq.url") as string,
|
||||
exchanges: [
|
||||
{
|
||||
name: PAYMENT_EVENTS_EXCHANGE,
|
||||
type: "topic",
|
||||
options: { durable: true },
|
||||
},
|
||||
{ name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } },
|
||||
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
|
||||
],
|
||||
queues: [
|
||||
@@ -49,6 +44,15 @@ const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||
connectionInitOptions: { wait: false },
|
||||
}),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SeatsModule,
|
||||
TicketsModule,
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
...rabbitMQImport(),
|
||||
],
|
||||
controllers: [PaymentsController, InternalPaymentsController],
|
||||
providers: [
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user