mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
resolve merge conflict
This commit is contained in:
@@ -369,7 +369,7 @@ pnpm --filter @edr/passenger-api run prisma:seed
|
|||||||
- 3 User accounts (Admin, Passenger, Agent)
|
- 3 User accounts (Admin, Passenger, Agent)
|
||||||
- Fare rules for ADULT and CHILD passenger categories
|
- Fare rules for ADULT and CHILD passenger categories
|
||||||
- Currency exchange rates (ETB, DJF, USD)
|
- Currency exchange rates (ETB, DJF, USD)
|
||||||
- Baggage allowance rules
|
- Luggage allowance rules
|
||||||
- Notification templates
|
- Notification templates
|
||||||
- Promotions and FAQ content
|
- Promotions and FAQ content
|
||||||
- Menu items and station crowd signals
|
- Menu items and station crowd signals
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staging table for customer profile edits that require backoffice review. An
|
||||||
|
* already-approved company's settings edits are snapshotted here (Pending)
|
||||||
|
* instead of being written to the live `companies` row; a reviewer approves
|
||||||
|
* (snapshot applied) or rejects with a note (customer amends & resubmits).
|
||||||
|
*/
|
||||||
|
export class CreateCompanyChangeRequest2000000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'CreateCompanyChangeRequest2000000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'company_change_request',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||||
|
{ name: 'company_id', type: 'uuid' },
|
||||||
|
{ name: 'snapshot', type: 'jsonb' },
|
||||||
|
{ name: 'documents', type: 'jsonb', isNullable: true },
|
||||||
|
{ name: 'status', type: 'varchar', length: '20', default: "'pending'" },
|
||||||
|
{ name: 'note', type: 'text', isNullable: true },
|
||||||
|
{ name: 'submitted_by', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'submitted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'reviewed_by', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
foreignKeys: [
|
||||||
|
{
|
||||||
|
columnNames: ['company_id'],
|
||||||
|
referencedSchema: 'freight',
|
||||||
|
referencedTableName: 'companies',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.company_change_request',
|
||||||
|
new TableIndex({ name: 'idx_company_change_request_company', columnNames: ['company_id'] }),
|
||||||
|
);
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.company_change_request',
|
||||||
|
new TableIndex({ name: 'idx_company_change_request_status', columnNames: ['status'] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('freight.company_change_request', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds reviewer note/id/timestamp to company_profiles so a rejected operational
|
||||||
|
* role (new ProfileStatus 'rejected') can carry the reason back to the customer,
|
||||||
|
* who can then amend and reapply.
|
||||||
|
*/
|
||||||
|
export class AddCompanyProfileReview2000000000001
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddCompanyProfileReview2000000000001';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.addColumns('freight.company_profiles', [
|
||||||
|
new TableColumn({ name: 'review_note', type: 'text', isNullable: true }),
|
||||||
|
new TableColumn({ name: 'reviewed_by', type: 'uuid', isNullable: true }),
|
||||||
|
new TableColumn({ name: 'reviewed_at', type: 'timestamptz', isNullable: true }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropColumns('freight.company_profiles', [
|
||||||
|
'review_note',
|
||||||
|
'reviewed_by',
|
||||||
|
'reviewed_at',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weights are tonnes everywhere. Warehouse / yard / zone capacity was stored in
|
||||||
|
* kg (e.g. 25000, 5000, 2500) — convert existing rows to tonnes (÷1000). Cargo
|
||||||
|
* weight (warehouse_inventory.weight ← cargo_total_weight_vgm) is already tonnes
|
||||||
|
* and is NOT touched; truck gross weight has no data yet. Runs exactly once
|
||||||
|
* (tracked by TypeORM) — re-running would divide again.
|
||||||
|
*/
|
||||||
|
export class WarehouseCapacityKgToTons2020000000000 implements MigrationInterface {
|
||||||
|
name = 'WarehouseCapacityKgToTons2020000000000';
|
||||||
|
|
||||||
|
private readonly tables = ['warehouses', 'warehouse_yards', 'warehouse_zones'];
|
||||||
|
private readonly columns = ['capacity_weight', 'current_weight', 'max_weight'];
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
for (const table of this.tables) {
|
||||||
|
for (const column of this.columns) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE freight.${table} SET ${column} = ${column} / 1000.0 WHERE ${column} IS NOT NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
for (const table of this.tables) {
|
||||||
|
for (const column of this.columns) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE freight.${table} SET ${column} = ${column} * 1000.0 WHERE ${column} IS NOT NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business-license files used to live inline as a jsonb array on
|
||||||
|
* `company_profiles.business_license_files`. They now belong to the FileRecord
|
||||||
|
* model (`freight.files`, resource `company_profiles`, code `business_license`)
|
||||||
|
* so they get stable ids and stream through `GET /api/files/:id` — the same
|
||||||
|
* proxy path regular documents use — instead of broken direct-MinIO URLs.
|
||||||
|
*
|
||||||
|
* This copies each existing inline entry into `freight.files` by reference
|
||||||
|
* (keeping the stored object URL — no bytes are re-uploaded). The original jsonb
|
||||||
|
* column is left intact for rollback safety.
|
||||||
|
*/
|
||||||
|
export class MigrateLicenseFilesToFileRecords2040000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "MigrateLicenseFilesToFileRecords2040000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.files
|
||||||
|
(id, resource_id, resource, code, name, url, size, mime_type, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
cp.id,
|
||||||
|
'company_profiles',
|
||||||
|
'business_license',
|
||||||
|
COALESCE(elem->>'name', 'license'),
|
||||||
|
elem->>'url',
|
||||||
|
COALESCE(NULLIF(elem->>'size', '')::int, 0),
|
||||||
|
COALESCE(NULLIF(elem->>'mimeType', ''), 'application/octet-stream'),
|
||||||
|
now(),
|
||||||
|
now()
|
||||||
|
FROM freight.company_profiles cp
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(cp.business_license_files) AS elem
|
||||||
|
WHERE cp.business_license_files IS NOT NULL
|
||||||
|
AND jsonb_typeof(cp.business_license_files) = 'array'
|
||||||
|
AND elem->>'url' IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.files f
|
||||||
|
WHERE f.resource_id = cp.id
|
||||||
|
AND f.resource = 'company_profiles'
|
||||||
|
AND f.code = 'business_license'
|
||||||
|
AND f.url = elem->>'url'
|
||||||
|
AND f.deleted_at IS NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Reverse the model migration by dropping the license FileRecords. The
|
||||||
|
// original jsonb column was never cleared, so the data still exists there.
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM freight.files
|
||||||
|
WHERE resource = 'company_profiles'
|
||||||
|
AND code = 'business_license';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds customer_truck_containers.loaded_at so an assignment (customer planning
|
||||||
|
* which containers ride which truck) is distinct from the container actually
|
||||||
|
* being loaded. Stage LOADED now requires loaded_at; customer assignment alone
|
||||||
|
* keeps the container at its prior stage (RECEIVED/GRN) with its planned truck
|
||||||
|
* shown. Backfills containers on already-departed trucks (they left loaded).
|
||||||
|
*/
|
||||||
|
export class AddCustomerTruckContainerLoadedAt2050000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_containers
|
||||||
|
ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.customer_truck_containers ctc
|
||||||
|
SET loaded_at = a.departed_at
|
||||||
|
FROM freight.customer_truck_assignments a
|
||||||
|
WHERE a.id = ctc.assignment_id
|
||||||
|
AND a.departed_at IS NOT NULL
|
||||||
|
AND ctc.deleted_at IS NULL
|
||||||
|
AND ctc.loaded_at IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
|||||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||||
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
|
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
|
||||||
import { CustomerTruckService } from './customer-truck.service';
|
import { CustomerTruckService } from './customer-truck.service';
|
||||||
|
import { FirstMileService } from '../first-mile/first-mile.service';
|
||||||
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||||
import { ContainerReceiptService } from './container-receipt.service';
|
import { ContainerReceiptService } from './container-receipt.service';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
@@ -81,6 +83,60 @@ import {
|
|||||||
hasFreightPermission,
|
hasFreightPermission,
|
||||||
} from "../../common/freight-permission.util";
|
} from "../../common/freight-permission.util";
|
||||||
|
|
||||||
|
interface MileVehicleSummary {
|
||||||
|
plate: string | null;
|
||||||
|
code: string | null;
|
||||||
|
driverName: string | null;
|
||||||
|
containerNumber: string | null;
|
||||||
|
distanceKm: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MileLegSummary {
|
||||||
|
status: string;
|
||||||
|
exactKm: number | null;
|
||||||
|
remainingPayment: number | null;
|
||||||
|
currency: string;
|
||||||
|
invoiced: boolean;
|
||||||
|
vehicles: MileVehicleSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trim a first/last-mile record down to a customer-safe operational summary. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function summarizeMileLeg(rec?: Record<string, any>): MileLegSummary | null {
|
||||||
|
if (!rec) return null;
|
||||||
|
const num = (v: unknown) => (v == null ? null : Number(v));
|
||||||
|
const assignments: Array<Record<string, any>> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
const currency =
|
||||||
|
rec.vehicle?.currency ??
|
||||||
|
assignments[0]?.vehicle?.currency ??
|
||||||
|
rec.booking?.paymentCurrency ??
|
||||||
|
'ETB';
|
||||||
|
const vehicles: MileVehicleSummary[] = assignments.map((a) => ({
|
||||||
|
plate: a.vehicle?.plateNumber ?? null,
|
||||||
|
code: a.vehicle?.code ?? null,
|
||||||
|
driverName: a.vehicle?.assignedDriverName ?? null,
|
||||||
|
containerNumber: a.containerNumber ?? null,
|
||||||
|
distanceKm: num(a.distanceKm),
|
||||||
|
}));
|
||||||
|
if (!vehicles.length && rec.vehicle) {
|
||||||
|
vehicles.push({
|
||||||
|
plate: rec.vehicle.plateNumber ?? null,
|
||||||
|
code: rec.vehicle.code ?? null,
|
||||||
|
driverName: rec.vehicle.assignedDriverName ?? null,
|
||||||
|
containerNumber: null,
|
||||||
|
distanceKm: num(rec.exactKm),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: rec.status ?? '',
|
||||||
|
exactKm: num(rec.exactKm),
|
||||||
|
remainingPayment: num(rec.remainingPayment),
|
||||||
|
currency,
|
||||||
|
invoiced: Boolean(rec.invoice),
|
||||||
|
vehicles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ApiTags("bookings")
|
@ApiTags("bookings")
|
||||||
@Controller("bookings")
|
@Controller("bookings")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -94,6 +150,8 @@ export class BookingsController {
|
|||||||
private readonly bookingClearanceService: BookingClearanceService,
|
private readonly bookingClearanceService: BookingClearanceService,
|
||||||
private readonly customerTruckService: CustomerTruckService,
|
private readonly customerTruckService: CustomerTruckService,
|
||||||
private readonly containerReceiptService: ContainerReceiptService,
|
private readonly containerReceiptService: ContainerReceiptService,
|
||||||
|
private readonly firstMileService: FirstMileService,
|
||||||
|
private readonly lastMileService: LastMileService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -290,6 +348,33 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/mile-summary')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
||||||
|
})
|
||||||
|
async mileSummary(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// Customers may only see their own booking's mile summary.
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
if (
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||||||
|
) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [first, last] = await Promise.all([
|
||||||
|
this.firstMileService.findAll({ bookingId: id, pageSize: 1 }),
|
||||||
|
this.lastMileService.findAll({ bookingId: id, pageSize: 1 }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
firstMile: summarizeMileLeg(first.data[0]),
|
||||||
|
lastMile: summarizeMileLeg(last.data[0]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/customer-truck-assignment')
|
@Post(':id/customer-truck-assignment')
|
||||||
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
|
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
|
||||||
async assignCustomerTruck(
|
async assignCustomerTruck(
|
||||||
@@ -350,6 +435,21 @@ export class BookingsController {
|
|||||||
return this.customerTruckService.addTruck(id, dto);
|
return this.customerTruckService.addTruck(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/customer-trucks/:assignmentId')
|
||||||
|
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
||||||
|
async updateCustomerTruck(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||||
|
@Body() dto: AddCustomerTruckDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
}
|
||||||
|
return this.customerTruckService.updateTruck(id, assignmentId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id/customer-trucks/:assignmentId')
|
@Delete(':id/customer-trucks/:assignmentId')
|
||||||
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
|
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
|
||||||
async removeCustomerTruck(
|
async removeCustomerTruck(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
|
|||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||||
|
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingInvoiceService } from './booking-invoice.service';
|
import { BookingInvoiceService } from './booking-invoice.service';
|
||||||
// import { BookingPaymentController } from './booking-payment.controller';
|
// import { BookingPaymentController } from './booking-payment.controller';
|
||||||
@@ -70,6 +71,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
forwardRef(() => FirstMileModule),
|
forwardRef(() => FirstMileModule),
|
||||||
|
forwardRef(() => LastMileModule),
|
||||||
forwardRef(() => TrainSchedulingModule),
|
forwardRef(() => TrainSchedulingModule),
|
||||||
forwardRef(() => ContractsModule),
|
forwardRef(() => ContractsModule),
|
||||||
forwardRef(() => ContractsModule),
|
forwardRef(() => ContractsModule),
|
||||||
|
|||||||
@@ -1424,6 +1424,20 @@ export class BookingsService {
|
|||||||
schedule?.status ?? null;
|
schedule?.status ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
|
||||||
|
// delivery from the portal (booking-based, one per booking). EDR last-mile
|
||||||
|
// handovers are per delivering truck and signed by the receiver at the door,
|
||||||
|
// so they never surface the portal "Approve delivery" action.
|
||||||
|
const [pendingHandover] = await this.dataSource.query(
|
||||||
|
`SELECT 1 FROM freight.booking_handovers
|
||||||
|
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||||
|
AND mile_type = 'SELF_HAUL'
|
||||||
|
LIMIT 1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
(booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
|
||||||
|
Boolean(pendingHandover);
|
||||||
|
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,21 +42,30 @@ export class CustomerTruckService {
|
|||||||
const booking = await this.loadBookingGuard(bookingId);
|
const booking = await this.loadBookingGuard(bookingId);
|
||||||
this.assertSelfHaulPaid(booking);
|
this.assertSelfHaulPaid(booking);
|
||||||
|
|
||||||
const isExport = booking.tradeDirection === 'EXPORT';
|
|
||||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
|
|
||||||
// EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are
|
// Both import and export specify the containers each truck carries. Capacity
|
||||||
// not pre-specified — they are registered + weighed when the truck leaves.
|
// is size-based: a 40ft container fills the truck (max 1); two 20ft containers
|
||||||
if (isExport) {
|
// fit (max 2), no size mixing. #trucks <= #containers follows naturally since
|
||||||
if (requested.length < 1 || requested.length > 2) {
|
// each container is assigned to exactly one truck.
|
||||||
throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
|
if (requested.length < 1) {
|
||||||
}
|
throw new BadRequestException('Select at least one container for this truck');
|
||||||
} else if (requested.length > 2) {
|
}
|
||||||
|
if (requested.length > 2) {
|
||||||
throw new BadRequestException('A truck carries at most 2 containers');
|
throw new BadRequestException('A truck carries at most 2 containers');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requested.length) {
|
if (requested.length) {
|
||||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
|
// Never assign more trucks than the booking has containers.
|
||||||
|
const existingTrucks = await this.dataSource
|
||||||
|
.getRepository(CustomerTruckAssignment)
|
||||||
|
.count({ where: { bookingId } });
|
||||||
|
if (existingTrucks + 1 > bookingNumbers.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const n of requested) {
|
for (const n of requested) {
|
||||||
if (!bookingNumbers.includes(n)) {
|
if (!bookingNumbers.includes(n)) {
|
||||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||||
@@ -68,6 +77,13 @@ export class CustomerTruckService {
|
|||||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Size cap: a 40ft container fills the truck.
|
||||||
|
const sizes = await this.containerSizes(bookingId, requested);
|
||||||
|
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -133,6 +149,76 @@ export class CustomerTruckService {
|
|||||||
return this.listTrucks(bookingId);
|
return this.listTrucks(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a truck assignment — plate/driver/type and the containers it carries.
|
||||||
|
* Allowed only until the truck has arrived (same guard as removal). Container
|
||||||
|
* rules mirror {@link addTruck}: 1–2 of the booking's containers, none already
|
||||||
|
* on another truck, and a 40ft container fills the truck (max 1).
|
||||||
|
*/
|
||||||
|
async updateTruck(
|
||||||
|
bookingId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
dto: AddCustomerTruckDto,
|
||||||
|
): Promise<CustomerTruckAssignment[]> {
|
||||||
|
const booking = await this.loadBookingGuard(bookingId);
|
||||||
|
this.assertSelfHaulPaid(booking);
|
||||||
|
|
||||||
|
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
|
||||||
|
if (!assignment || assignment.bookingId !== bookingId) {
|
||||||
|
throw new NotFoundException('Truck assignment not found for this booking');
|
||||||
|
}
|
||||||
|
if (assignment.arrivedAt) {
|
||||||
|
throw new ConflictException('Cannot edit a truck that has already arrived');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
|
if (requested.length < 1) {
|
||||||
|
throw new BadRequestException('Select at least one container for this truck');
|
||||||
|
}
|
||||||
|
if (requested.length > 2) {
|
||||||
|
throw new BadRequestException('A truck carries at most 2 containers');
|
||||||
|
}
|
||||||
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (!bookingNumbers.includes(n)) {
|
||||||
|
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||||
|
const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (assignedElsewhere.includes(n)) {
|
||||||
|
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sizes = await this.containerSizes(bookingId, requested);
|
||||||
|
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||||
|
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||||
|
driverName: dto.driverName.trim(),
|
||||||
|
truckType: dto.truckType.trim(),
|
||||||
|
});
|
||||||
|
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||||
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
|
requested.map((containerNumber) =>
|
||||||
|
manager.getRepository(CustomerTruckContainer).create({
|
||||||
|
assignmentId,
|
||||||
|
bookingId,
|
||||||
|
containerNumber,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.listTrucks(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register an IMPORT self-haul truck leaving the port: the containers it
|
* Register an IMPORT self-haul truck leaving the port: the containers it
|
||||||
* actually loaded (replacing any provisional list) and its weighed gross.
|
* actually loaded (replacing any provisional list) and its weighed gross.
|
||||||
@@ -226,6 +312,13 @@ export class CustomerTruckService {
|
|||||||
if (assignment.departedAt) {
|
if (assignment.departedAt) {
|
||||||
throw new ConflictException('This truck has already left — its load is locked');
|
throw new ConflictException('This truck has already left — its load is locked');
|
||||||
}
|
}
|
||||||
|
// Containers can only be loaded after the truck has physically arrived at the
|
||||||
|
// warehouse (arrival weighing recorded). Assignment alone is just planning.
|
||||||
|
if (!assignment.arrivedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
if (!requested.length) {
|
if (!requested.length) {
|
||||||
@@ -244,30 +337,35 @@ export class CustomerTruckService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const grossKg = await this.vgmKgForContainers(bookingId, requested);
|
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||||
|
// Operator loading the truck: stamp loaded_at so these containers move to
|
||||||
|
// the LOADED stage (customer assignment alone leaves loaded_at null).
|
||||||
|
const loadedAt = new Date();
|
||||||
await manager.getRepository(CustomerTruckContainer).save(
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
requested.map((containerNumber) =>
|
requested.map((containerNumber) =>
|
||||||
manager.getRepository(CustomerTruckContainer).create({
|
manager.getRepository(CustomerTruckContainer).create({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
bookingId,
|
bookingId,
|
||||||
containerNumber,
|
containerNumber,
|
||||||
|
loadedAt,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Provisional gross from the loaded containers' VGM — overridden by the
|
// Provisional gross (tonnes) from the loaded containers' VGM — overridden
|
||||||
// weighed gross on departure.
|
// by the weighed gross on departure. (Column is *_kg but holds tonnes.)
|
||||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||||
grossWeightKg: grossKg,
|
grossWeightKg: grossTons,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return this.listTrucks(bookingId);
|
return this.listTrucks(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
|
/** Summed VGM (tonnes) of the given containers — provisional truck gross. */
|
||||||
const [row]: Array<{ kg: string }> = await this.dataSource.query(
|
private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise<number> {
|
||||||
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
|
const [row]: Array<{ tons: string }> = await this.dataSource.query(
|
||||||
|
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons
|
||||||
FROM freight.booking_container_units bcu
|
FROM freight.booking_container_units bcu
|
||||||
JOIN freight.booking_container bc
|
JOIN freight.booking_container bc
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
@@ -276,7 +374,7 @@ export class CustomerTruckService {
|
|||||||
AND bcu.deleted_at IS NULL`,
|
AND bcu.deleted_at IS NULL`,
|
||||||
[bookingId, numbers],
|
[bookingId, numbers],
|
||||||
);
|
);
|
||||||
return Number(row?.kg ?? 0);
|
return Number(row?.tons ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -399,4 +497,20 @@ export class CustomerTruckService {
|
|||||||
);
|
);
|
||||||
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||||
|
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||||||
|
if (!numbers.length) return [];
|
||||||
|
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT bc.container_size AS "size"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1
|
||||||
|
AND UPPER(bcu.container_number) = ANY($2)
|
||||||
|
AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId, numbers],
|
||||||
|
);
|
||||||
|
return rows.map((r) => (r.size ?? '').trim());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
||||||
containerNumber!: string;
|
containerNumber!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the container was actually loaded onto the truck by the operator.
|
||||||
|
* Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires
|
||||||
|
* this to be set, so customer assignment alone does not mark a container loaded.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||||
|
loadedAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
|
BadRequestException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||||
@@ -33,7 +34,7 @@ import {
|
|||||||
ResponseCompanyDto,
|
ResponseCompanyDto,
|
||||||
ResponseCompanyProfileDto,
|
ResponseCompanyProfileDto,
|
||||||
} from "./dto/response-company.dto";
|
} from "./dto/response-company.dto";
|
||||||
import { BusinessLicenseFile } from "./entities/company-profile.entity";
|
import { ProfileLicenseFileView } from "./entities/company-profile.entity";
|
||||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
@@ -43,6 +44,8 @@ import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
|||||||
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
||||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||||
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
||||||
|
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
||||||
|
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
||||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||||
|
|
||||||
@@ -61,6 +64,25 @@ export class CompaniesController {
|
|||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* License files are FileRecord-backed and previewed through `GET /api/files/:id`
|
||||||
|
* (the client builds that URL from the returned `id`). Populate each profile
|
||||||
|
* DTO's `licenseFiles` with its live/pending files in one batched lookup.
|
||||||
|
*/
|
||||||
|
private async populateLicenseFiles(
|
||||||
|
companyId: string,
|
||||||
|
profiles: { id: string; licenseFiles: ProfileLicenseFileView[] }[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (profiles.length === 0) return;
|
||||||
|
const byProfile = await this.companiesService.assembleLicenseFilesByProfile(
|
||||||
|
companyId,
|
||||||
|
profiles.map((p) => p.id),
|
||||||
|
);
|
||||||
|
for (const p of profiles) {
|
||||||
|
p.licenseFiles = byProfile[p.id] ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get("getInfo")
|
@Get("getInfo")
|
||||||
@ApiOperation({ summary: "Get company info for the current user" })
|
@ApiOperation({ summary: "Get company info for the current user" })
|
||||||
async getInfo(
|
async getInfo(
|
||||||
@@ -68,7 +90,10 @@ export class CompaniesController {
|
|||||||
): Promise<CompanyInfoResponseDto> {
|
): Promise<CompanyInfoResponseDto> {
|
||||||
const { profile, company } =
|
const { profile, company } =
|
||||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||||
return new CompanyInfoResponseDto(profile, company);
|
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||||
|
company.id,
|
||||||
|
);
|
||||||
|
return new CompanyInfoResponseDto(profile, company, review);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("profile")
|
@Get("profile")
|
||||||
@@ -78,7 +103,42 @@ export class CompaniesController {
|
|||||||
): Promise<ProfileResponseDto> {
|
): Promise<ProfileResponseDto> {
|
||||||
const { profile, company } =
|
const { profile, company } =
|
||||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||||
return new ProfileResponseDto(profile, company);
|
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||||
|
company.id,
|
||||||
|
);
|
||||||
|
const dto = new ProfileResponseDto(profile, company, review);
|
||||||
|
await this.populateLicenseFiles(company.id, dto.companyProfiles);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("profile/change-request")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Current user's open profile change request (pending/rejected)",
|
||||||
|
})
|
||||||
|
async getMyChangeRequest(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<ChangeRequestResponseDto | null> {
|
||||||
|
const { company } =
|
||||||
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||||
|
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||||
|
company.id,
|
||||||
|
);
|
||||||
|
return review ? new ChangeRequestResponseDto(review) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("company-profiles/:profileId/reapply")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Resubmit a rejected operational role for approval (→ pending)",
|
||||||
|
})
|
||||||
|
async reapplyCompanyProfile(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
|
): Promise<ResponseCompanyProfileDto> {
|
||||||
|
const profile = await this.companiesService.reapplyCompanyProfile(
|
||||||
|
user.id,
|
||||||
|
profileId,
|
||||||
|
);
|
||||||
|
return new ResponseCompanyProfileDto(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("dashboard")
|
@Get("dashboard")
|
||||||
@@ -177,28 +237,72 @@ export class CompaniesController {
|
|||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Upload business-license document(s) for one of the current user's company profiles",
|
"Add business-license document(s) to a profile. For an approved company " +
|
||||||
|
"the upload is staged for backoffice review; during onboarding it goes live.",
|
||||||
})
|
})
|
||||||
async uploadProfileLicense(
|
async uploadProfileLicense(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||||
): Promise<BusinessLicenseFile[]> {
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
return this.companiesService.uploadProfileLicenseFiles(
|
return this.companiesService.addProfileLicenseFiles(
|
||||||
user.id,
|
user.id,
|
||||||
profileId,
|
profileId,
|
||||||
files,
|
files,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("company-profiles/:profileId/license/:fileId/replace")
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Replace a business-license file with a newly uploaded one (staged for " +
|
||||||
|
"review on an approved company).",
|
||||||
|
})
|
||||||
|
async replaceProfileLicense(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||||
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||||
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
|
const file = files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException("A replacement file is required");
|
||||||
|
}
|
||||||
|
return this.companiesService.replaceProfileLicenseFile(
|
||||||
|
user.id,
|
||||||
|
profileId,
|
||||||
|
fileId,
|
||||||
|
file,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("company-profiles/:profileId/license/:fileId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Remove a business-license file (staged for review on an approved company).",
|
||||||
|
})
|
||||||
|
async removeProfileLicense(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||||
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
|
return this.companiesService.removeProfileLicenseFile(
|
||||||
|
user.id,
|
||||||
|
profileId,
|
||||||
|
fileId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("company-profiles/:profileId/license")
|
@Get("company-profiles/:profileId/license")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List business-license documents for a company profile",
|
summary: "List business-license documents (with review state) for a profile",
|
||||||
})
|
})
|
||||||
async listProfileLicense(
|
async listProfileLicense(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
): Promise<BusinessLicenseFile[]> {
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +410,9 @@ export class CompaniesController {
|
|||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
): Promise<ResponseCompanyDto> {
|
): Promise<ResponseCompanyDto> {
|
||||||
const company = await this.companiesService.findCompanyById(id);
|
const company = await this.companiesService.findCompanyById(id);
|
||||||
return new ResponseCompanyDto(company);
|
const dto = new ResponseCompanyDto(company);
|
||||||
|
await this.populateLicenseFiles(company.id, dto.companyProfiles ?? []);
|
||||||
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
@@ -354,26 +460,76 @@ export class CompaniesController {
|
|||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
|
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
|
||||||
async uploadDocuments(
|
async uploadDocuments(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||||
) {
|
) {
|
||||||
return this.filesService.uploadMany(companyId, "companies", files);
|
// Routed through the service so an approved company's uploads are staged for
|
||||||
|
// review (and lock the customer), while onboarding uploads pass straight through.
|
||||||
|
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("company-profiles/:profileId/status")
|
@Patch("company-profiles/:profileId/status")
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Update a company profile's approval status" })
|
@ApiOperation({ summary: "Update a company profile's approval status" })
|
||||||
async updateCompanyProfileStatus(
|
async updateCompanyProfileStatus(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
@Body() dto: UpdateCompanyProfileStatusDto,
|
@Body() dto: UpdateCompanyProfileStatusDto,
|
||||||
): Promise<ResponseCompanyProfileDto> {
|
): Promise<ResponseCompanyProfileDto> {
|
||||||
const profile = await this.companiesService.setCompanyProfileStatus(
|
const profile = await this.companiesService.setCompanyProfileStatus(
|
||||||
profileId,
|
profileId,
|
||||||
dto.status,
|
dto.status,
|
||||||
|
dto.note,
|
||||||
|
user.id,
|
||||||
);
|
);
|
||||||
return new ResponseCompanyProfileDto(profile);
|
return new ResponseCompanyProfileDto(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(":companyId/change-requests")
|
||||||
|
@FreightAdmin()
|
||||||
|
@ApiOperation({ summary: "List a company's profile change requests" })
|
||||||
|
async listChangeRequests(
|
||||||
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
): Promise<ChangeRequestResponseDto[]> {
|
||||||
|
const requests = await this.companiesService.listChangeRequests(companyId);
|
||||||
|
return requests.map((r) => new ChangeRequestResponseDto(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("change-requests/:id/approve")
|
||||||
|
@FreightAdmin()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Approve a pending profile change request (applies the changes)",
|
||||||
|
})
|
||||||
|
async approveChangeRequest(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
): Promise<ChangeRequestResponseDto> {
|
||||||
|
const request = await this.companiesService.approveChangeRequest(
|
||||||
|
id,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
return new ChangeRequestResponseDto(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("change-requests/:id/reject")
|
||||||
|
@FreightAdmin()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Reject a pending profile change request with a note",
|
||||||
|
})
|
||||||
|
async rejectChangeRequest(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RejectChangeRequestDto,
|
||||||
|
): Promise<ChangeRequestResponseDto> {
|
||||||
|
const request = await this.companiesService.rejectChangeRequest(
|
||||||
|
id,
|
||||||
|
dto.note,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
return new ChangeRequestResponseDto(request);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":companyId/profiles")
|
@Post(":companyId/profiles")
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||||
|
|||||||
@@ -12,13 +12,21 @@ import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
|||||||
import { Company } from "./entities/company.entity";
|
import { Company } from "./entities/company.entity";
|
||||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||||
import { CompanyProfile } from "./entities/company-profile.entity";
|
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||||
|
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
TypeOrmModule.forFeature([
|
||||||
|
Company,
|
||||||
|
ExternalProfile,
|
||||||
|
CompanyProfile,
|
||||||
|
CompanyChangeRequest,
|
||||||
|
Booking,
|
||||||
|
]),
|
||||||
HttpModule,
|
HttpModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
@@ -30,6 +38,7 @@ import { ETradeService } from "./services/etrade.service";
|
|||||||
CompaniesRepository,
|
CompaniesRepository,
|
||||||
ExternalProfileRepository,
|
ExternalProfileRepository,
|
||||||
CompanyProfileRepository,
|
CompanyProfileRepository,
|
||||||
|
CompanyChangeRequestRepository,
|
||||||
CompanyDashboardRepository,
|
CompanyDashboardRepository,
|
||||||
ETradeService,
|
ETradeService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||||
import {
|
import {
|
||||||
CompanyDashboardRepository,
|
CompanyDashboardRepository,
|
||||||
DashboardScope,
|
DashboardScope,
|
||||||
} from "./company-dashboard.repository";
|
} from "./company-dashboard.repository";
|
||||||
import { MinioService } from "../minio/minio.service";
|
|
||||||
import { FilesService } from "../files/files.service";
|
import { FilesService } from "../files/files.service";
|
||||||
|
import { FileRecord } from "../files/entities/file.entity";
|
||||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||||
@@ -37,9 +38,21 @@ import { ExternalProfile } from "./entities/external-profile.entity";
|
|||||||
import {
|
import {
|
||||||
BusinessLicenseFile,
|
BusinessLicenseFile,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
|
ProfileLicenseFileView,
|
||||||
ProfileType,
|
ProfileType,
|
||||||
ProfileStatus,
|
ProfileStatus,
|
||||||
} from "./entities/company-profile.entity";
|
} from "./entities/company-profile.entity";
|
||||||
|
import {
|
||||||
|
ChangeRequestStatus,
|
||||||
|
CompanyChangeRequest,
|
||||||
|
LicenseChangeIntent,
|
||||||
|
} from "./entities/company-change-request.entity";
|
||||||
|
|
||||||
|
/** FileRecord `resource` + `code` slots for business-license documents. */
|
||||||
|
const LICENSE_RESOURCE = "company_profiles";
|
||||||
|
const LICENSE_CODE = "business_license";
|
||||||
|
/** Code for a license file staged in an open change request (not yet live). */
|
||||||
|
const LICENSE_PENDING_CODE = "business_license_pending";
|
||||||
|
|
||||||
export interface UserIdentity {
|
export interface UserIdentity {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -54,9 +67,9 @@ export class CompaniesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly companiesRepo: CompaniesRepository,
|
private readonly companiesRepo: CompaniesRepository,
|
||||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||||
|
private readonly changeRequestRepo: CompanyChangeRequestRepository,
|
||||||
private readonly profilesRepo: ExternalProfileRepository,
|
private readonly profilesRepo: ExternalProfileRepository,
|
||||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||||
private readonly minioService: MinioService,
|
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
private readonly etradeService: ETradeService,
|
private readonly etradeService: ETradeService,
|
||||||
@@ -334,28 +347,9 @@ export class CompaniesService {
|
|||||||
const company = await this.companiesRepo.findById(id);
|
const company = await this.companiesRepo.findById(id);
|
||||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
||||||
for (const profile of company.companyProfiles) {
|
|
||||||
profile.businessLicenseFiles = await this.signLicenseFiles(
|
|
||||||
profile.businessLicenseFiles,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return company;
|
return company;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Business-license files are stored as raw, unsigned MinIO URLs (see
|
|
||||||
* `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them
|
|
||||||
* directly. Sign each one with a short-lived URL before it reaches a response.
|
|
||||||
*/
|
|
||||||
private async signLicenseFiles(
|
|
||||||
files?: BusinessLicenseFile[] | null,
|
|
||||||
): Promise<BusinessLicenseFile[]> {
|
|
||||||
if (!files?.length) return [];
|
|
||||||
return Promise.all(
|
|
||||||
files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate an explicitly-chosen company profile for a booking: it must belong
|
* Validate an explicitly-chosen company profile for a booking: it must belong
|
||||||
* to the booking's company and be Active. Used for government bookings (staff
|
* to the booking's company and be Active. Used for government bookings (staff
|
||||||
@@ -574,12 +568,24 @@ export class CompaniesService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(
|
/** Keep only the keys that were actually provided (drop `undefined`). */
|
||||||
userId: string,
|
private pickDefined(dto: Record<string, any>): Record<string, any> {
|
||||||
dto: UpdateProfileDto,
|
const out: Record<string, any> = {};
|
||||||
): Promise<ProfileResponseDto> {
|
for (const [k, v] of Object.entries(dto)) {
|
||||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
if (v !== undefined) out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
|
||||||
|
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
|
||||||
|
* PoA live there). Pure — the caller runs the async TIN-uniqueness check.
|
||||||
|
*/
|
||||||
|
private mapProfileDtoToCompanyUpdates(
|
||||||
|
company: Company,
|
||||||
|
dto: Partial<UpdateProfileDto>,
|
||||||
|
): Record<string, any> {
|
||||||
const companyUpdates: Record<string, any> = {};
|
const companyUpdates: Record<string, any> = {};
|
||||||
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
||||||
|
|
||||||
@@ -593,21 +599,10 @@ export class CompaniesService {
|
|||||||
companyUpdates.country = dto.companyLocation;
|
companyUpdates.country = dto.companyLocation;
|
||||||
if (dto.companyAddress !== undefined)
|
if (dto.companyAddress !== undefined)
|
||||||
companyUpdates.address = dto.companyAddress;
|
companyUpdates.address = dto.companyAddress;
|
||||||
if (dto.tin !== undefined && dto.tin !== company.tin) {
|
if (dto.tin !== undefined && dto.tin !== company.tin)
|
||||||
// Reject a TIN already taken by a different company (the user's own draft
|
|
||||||
// placeholder is fine to overwrite).
|
|
||||||
const owner = await this.companiesRepo.findByTin(dto.tin);
|
|
||||||
if (owner && owner.id !== company.id) {
|
|
||||||
throw new ConflictException(
|
|
||||||
`This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
companyUpdates.tin = dto.tin;
|
companyUpdates.tin = dto.tin;
|
||||||
}
|
|
||||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||||
if (dto.fanNumber !== undefined) {
|
if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
|
||||||
companyUpdates.fanNumber = dto.fanNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.contactPersonName !== undefined)
|
if (dto.contactPersonName !== undefined)
|
||||||
attrUpdates.contactPersonName = dto.contactPersonName;
|
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||||
@@ -629,8 +624,7 @@ export class CompaniesService {
|
|||||||
if (dto.poaPhone !== undefined)
|
if (dto.poaPhone !== undefined)
|
||||||
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
|
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
|
||||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||||
if (dto.poaLocation !== undefined)
|
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
|
||||||
attrUpdates.poaLocation = dto.poaLocation;
|
|
||||||
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||||
|
|
||||||
if (dto.licenceNumber !== undefined)
|
if (dto.licenceNumber !== undefined)
|
||||||
@@ -653,11 +647,219 @@ export class CompaniesService {
|
|||||||
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
|
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
|
||||||
|
|
||||||
companyUpdates.attributes = attrUpdates;
|
companyUpdates.attributes = attrUpdates;
|
||||||
|
return companyUpdates;
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
/** Reject a TIN already registered to a *different* company. */
|
||||||
if (!updated)
|
private async assertTinAvailable(
|
||||||
throw new NotFoundException(`Company ${company.id} not found`);
|
company: Company,
|
||||||
return new ProfileResponseDto(profile, updated);
|
tin: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
if (tin === undefined || tin === company.tin) return;
|
||||||
|
const owner = await this.companiesRepo.findByTin(tin);
|
||||||
|
if (owner && owner.id !== company.id) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`This TIN (${tin}) is already registered to another company. Please check the number and try again.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The company's open (pending or last-rejected) profile change request. */
|
||||||
|
async getOpenChangeRequestForCompany(
|
||||||
|
companyId: string,
|
||||||
|
): Promise<CompanyChangeRequest | null> {
|
||||||
|
return this.changeRequestRepo.findLatestOpenByCompanyId(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current user's profile.
|
||||||
|
*
|
||||||
|
* - Company not yet approved (onboarding) → write straight to the Company row,
|
||||||
|
* as before. The company/role pending→approve gate already covers first-run.
|
||||||
|
* - Company already `active` → do NOT touch the live Company. Stage the edit in
|
||||||
|
* a pending change request (merging into any open one) so a backoffice
|
||||||
|
* reviewer can approve (apply) or reject (with a note). This locks the
|
||||||
|
* customer until the review resolves.
|
||||||
|
*/
|
||||||
|
async updateProfile(
|
||||||
|
userId: string,
|
||||||
|
dto: UpdateProfileDto,
|
||||||
|
): Promise<ProfileResponseDto> {
|
||||||
|
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
|
||||||
|
if (company.status !== CompanyStatus.Active) {
|
||||||
|
await this.assertTinAvailable(company, dto.tin);
|
||||||
|
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
|
||||||
|
const updated = await this.companiesRepo.update(
|
||||||
|
company.id,
|
||||||
|
companyUpdates,
|
||||||
|
);
|
||||||
|
if (!updated)
|
||||||
|
throw new NotFoundException(`Company ${company.id} not found`);
|
||||||
|
return new ProfileResponseDto(profile, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approved company: stage the change for review, leaving the live row intact.
|
||||||
|
await this.assertTinAvailable(company, dto.tin);
|
||||||
|
const fields = this.pickDefined(dto);
|
||||||
|
|
||||||
|
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
||||||
|
company.id,
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
let request: CompanyChangeRequest;
|
||||||
|
if (existing) {
|
||||||
|
request =
|
||||||
|
(await this.changeRequestRepo.update(existing.id, {
|
||||||
|
snapshot: { ...(existing.snapshot ?? {}), ...fields },
|
||||||
|
submittedBy: userId,
|
||||||
|
submittedAt: now,
|
||||||
|
note: null,
|
||||||
|
})) ?? existing;
|
||||||
|
} else {
|
||||||
|
request = await this.changeRequestRepo.create({
|
||||||
|
companyId: company.id,
|
||||||
|
snapshot: fields,
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
|
submittedBy: userId,
|
||||||
|
submittedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live company is unchanged; surface the pending state for the settings page.
|
||||||
|
return new ProfileResponseDto(profile, company, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List a company's change requests, newest first (backoffice review). */
|
||||||
|
async listChangeRequests(
|
||||||
|
companyId: string,
|
||||||
|
): Promise<CompanyChangeRequest[]> {
|
||||||
|
await this.findCompanyById(companyId);
|
||||||
|
return this.changeRequestRepo.findByCompanyId(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a pending change request: apply its snapshot to the live Company and
|
||||||
|
* mark the request approved. Any staged documents are already attached to the
|
||||||
|
* company, so nothing else needs promoting.
|
||||||
|
*/
|
||||||
|
async approveChangeRequest(
|
||||||
|
id: string,
|
||||||
|
reviewerId?: string,
|
||||||
|
): Promise<CompanyChangeRequest> {
|
||||||
|
const request = await this.changeRequestRepo.findById(id);
|
||||||
|
if (!request)
|
||||||
|
throw new NotFoundException(`Change request ${id} not found`);
|
||||||
|
if (request.status !== ChangeRequestStatus.Pending) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Change request ${id} is already ${request.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await this.companiesRepo.findById(request.companyId);
|
||||||
|
if (!company)
|
||||||
|
throw new NotFoundException(`Company ${request.companyId} not found`);
|
||||||
|
|
||||||
|
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
|
||||||
|
await this.assertTinAvailable(company, snapshot.tin);
|
||||||
|
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
||||||
|
await this.companiesRepo.update(company.id, companyUpdates);
|
||||||
|
await this.applyLicenseChanges(request);
|
||||||
|
|
||||||
|
return (
|
||||||
|
(await this.changeRequestRepo.update(id, {
|
||||||
|
status: ChangeRequestStatus.Approved,
|
||||||
|
reviewedBy: reviewerId ?? null,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
note: null,
|
||||||
|
})) ?? request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload company documents. For an approved company this also opens/updates a
|
||||||
|
* pending change request (recording the uploaded file ids) so the upload is
|
||||||
|
* reviewed and the customer is locked until it clears — consistent with the
|
||||||
|
* field-edit review. During onboarding (company not yet active) it's a plain
|
||||||
|
* upload with no review.
|
||||||
|
*/
|
||||||
|
async uploadCompanyDocuments(
|
||||||
|
companyId: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
submittedBy?: string,
|
||||||
|
): Promise<FileRecord[]> {
|
||||||
|
const company = await this.findCompanyById(companyId);
|
||||||
|
const uploaded = await this.filesService.uploadMany(
|
||||||
|
companyId,
|
||||||
|
"companies",
|
||||||
|
files,
|
||||||
|
);
|
||||||
|
if (company.status === CompanyStatus.Active) {
|
||||||
|
await this.stageDocumentChange(
|
||||||
|
company.id,
|
||||||
|
uploaded.map((f) => f.id),
|
||||||
|
submittedBy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return uploaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open or append a pending change request recording staged document uploads. */
|
||||||
|
private async stageDocumentChange(
|
||||||
|
companyId: string,
|
||||||
|
fileIds: string[],
|
||||||
|
submittedBy?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (fileIds.length === 0) return;
|
||||||
|
const now = new Date();
|
||||||
|
const existing =
|
||||||
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||||
|
if (existing) {
|
||||||
|
const prev = existing.documents?.documentFileIds ?? [];
|
||||||
|
await this.changeRequestRepo.update(existing.id, {
|
||||||
|
documents: { documentFileIds: [...prev, ...fileIds] },
|
||||||
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||||
|
submittedAt: now,
|
||||||
|
note: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.changeRequestRepo.create({
|
||||||
|
companyId,
|
||||||
|
snapshot: {},
|
||||||
|
documents: { documentFileIds: fileIds },
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
|
submittedBy: submittedBy ?? null,
|
||||||
|
submittedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject a pending change request with a note (customer amends & resubmits). */
|
||||||
|
async rejectChangeRequest(
|
||||||
|
id: string,
|
||||||
|
note: string,
|
||||||
|
reviewerId?: string,
|
||||||
|
): Promise<CompanyChangeRequest> {
|
||||||
|
const request = await this.changeRequestRepo.findById(id);
|
||||||
|
if (!request)
|
||||||
|
throw new NotFoundException(`Change request ${id} not found`);
|
||||||
|
if (request.status !== ChangeRequestStatus.Pending) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Change request ${id} is already ${request.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.discardLicenseChanges(request);
|
||||||
|
return (
|
||||||
|
(await this.changeRequestRepo.update(id, {
|
||||||
|
status: ChangeRequestStatus.Rejected,
|
||||||
|
// Staged license uploads were just discarded; drop their intents so an
|
||||||
|
// amended resubmit never re-references deleted files.
|
||||||
|
documents: { ...request.documents, licenseChanges: [] },
|
||||||
|
note,
|
||||||
|
reviewedBy: reviewerId ?? null,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
})) ?? request
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCompany(id: string): Promise<void> {
|
async deleteCompany(id: string): Promise<void> {
|
||||||
@@ -713,6 +915,8 @@ export class CompaniesService {
|
|||||||
async setCompanyProfileStatus(
|
async setCompanyProfileStatus(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
status: ProfileStatus,
|
status: ProfileStatus,
|
||||||
|
note?: string,
|
||||||
|
reviewerId?: string,
|
||||||
): Promise<CompanyProfile> {
|
): Promise<CompanyProfile> {
|
||||||
const existing = await this.companyProfilesRepo.findById(profileId);
|
const existing = await this.companyProfilesRepo.findById(profileId);
|
||||||
if (!existing)
|
if (!existing)
|
||||||
@@ -727,6 +931,18 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track the review outcome. Rejection keeps the note so the customer knows
|
||||||
|
// why; approval clears it. Any decision stamps the reviewer + time.
|
||||||
|
if (status === ProfileStatus.Rejected) {
|
||||||
|
patch.reviewNote = note ?? null;
|
||||||
|
} else if (status === ProfileStatus.Active) {
|
||||||
|
patch.reviewNote = null;
|
||||||
|
}
|
||||||
|
if (status !== ProfileStatus.Pending) {
|
||||||
|
patch.reviewedBy = reviewerId ?? null;
|
||||||
|
patch.reviewedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await this.companyProfilesRepo.update(profileId, patch);
|
const updated = await this.companyProfilesRepo.update(profileId, patch);
|
||||||
if (!updated)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
@@ -744,6 +960,41 @@ export class CompaniesService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer reapplies for a rejected operational role (after fixing whatever the
|
||||||
|
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
|
||||||
|
* clear the rejection note so it re-enters the approval queue.
|
||||||
|
*/
|
||||||
|
async reapplyCompanyProfile(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<CompanyProfile> {
|
||||||
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
|
if (!profile)
|
||||||
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
const companyId = profile.company?.id ?? profile.companyId;
|
||||||
|
|
||||||
|
const target = await this.companyProfilesRepo.findById(profileId);
|
||||||
|
if (!target || target.companyId !== companyId) {
|
||||||
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
|
}
|
||||||
|
if (target.status !== ProfileStatus.Rejected) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Only a rejected role can be resubmitted for approval",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.companyProfilesRepo.update(profileId, {
|
||||||
|
status: ProfileStatus.Pending,
|
||||||
|
reviewNote: null,
|
||||||
|
reviewedBy: null,
|
||||||
|
reviewedAt: null,
|
||||||
|
});
|
||||||
|
if (!updated)
|
||||||
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async createCompanyProfile(
|
async createCompanyProfile(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
profileType?: ProfileType,
|
profileType?: ProfileType,
|
||||||
@@ -833,12 +1084,12 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
if (existing) continue;
|
if (existing) continue;
|
||||||
|
|
||||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
// Self-service role adds start Pending and carry no reference — a reference
|
||||||
|
// is minted only when a backoffice reviewer approves the role.
|
||||||
await this.companyProfilesRepo.create({
|
await this.companyProfilesRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
type,
|
type,
|
||||||
reference,
|
status: ProfileStatus.Pending,
|
||||||
status: ProfileStatus.Active,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,13 +1121,14 @@ export class CompaniesService {
|
|||||||
|
|
||||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||||
if (!created) {
|
if (!created) {
|
||||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||||
|
// carry no reference until approved. The customer can select this mode but
|
||||||
|
// can't book under it until it's cleared.
|
||||||
created = await this.companyProfilesRepo.create({
|
created = await this.companyProfilesRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
type,
|
type,
|
||||||
reference,
|
|
||||||
businessLicense: businessLicense ?? null,
|
businessLicense: businessLicense ?? null,
|
||||||
status: ProfileStatus.Active,
|
status: ProfileStatus.Pending,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,13 +1223,21 @@ export class CompaniesService {
|
|||||||
}));
|
}));
|
||||||
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
||||||
|
|
||||||
// 3. Per-operational-profile business licenses.
|
// 3. Per-operational-profile business licenses (FileRecord-backed).
|
||||||
const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({
|
const licenseProfiles = await Promise.all(
|
||||||
profileId: p.id,
|
(company.companyProfiles ?? []).map(async (p) => {
|
||||||
type: p.type,
|
const records = await this.filesService.findByResource(
|
||||||
reference: p.reference ?? "",
|
p.id,
|
||||||
uploaded: (p.businessLicenseFiles?.length ?? 0) > 0,
|
LICENSE_RESOURCE,
|
||||||
}));
|
);
|
||||||
|
return {
|
||||||
|
profileId: p.id,
|
||||||
|
type: p.type,
|
||||||
|
reference: p.reference ?? "",
|
||||||
|
uploaded: records.some((r) => r.code === LICENSE_CODE),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
||||||
|
|
||||||
const outstanding = [
|
const outstanding = [
|
||||||
@@ -1094,61 +1354,321 @@ export class CompaniesService {
|
|||||||
return owned;
|
return owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Business-license files ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// License documents live in the FileRecord model (`freight.files`) with
|
||||||
|
// `resource = "company_profiles"`, `resourceId = <profileId>`. Live files use
|
||||||
|
// code `LICENSE_CODE`; files staged inside an open change request (add /
|
||||||
|
// replacement) use `LICENSE_PENDING_CODE` and only become live on approval.
|
||||||
|
// Preview streams through `GET /api/files/:id` (server-side proxy) — the same
|
||||||
|
// path regular documents use — so it never hits MinIO directly from the
|
||||||
|
// browser (which fails on the internal bucket endpoint).
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload business-license document(s) and store them directly on the company
|
* Upload business-license file(s) for one of the user's profiles. During
|
||||||
* profile (multi-file). Bytes go to object storage; only metadata/URLs are
|
* onboarding (company not yet Active) they go live immediately; for an Active
|
||||||
* persisted on the profile — intentionally not via the FileRecord file model.
|
* company they're staged under the pending code and recorded as `add` intents
|
||||||
* New files are appended to any already present. Returns the full list.
|
* on a pending change request for backoffice review. Returns the updated view.
|
||||||
*/
|
*/
|
||||||
async uploadProfileLicenseFiles(
|
async addProfileLicenseFiles(
|
||||||
userId: string,
|
userId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<BusinessLicenseFile[]> {
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||||
|
const company = await this.findCompanyById(profile.companyId);
|
||||||
|
const gated = company.status === CompanyStatus.Active;
|
||||||
|
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
||||||
|
|
||||||
const uploaded: BusinessLicenseFile[] = [];
|
const uploaded = await Promise.all(
|
||||||
for (const file of files) {
|
files.map((file) =>
|
||||||
const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`;
|
this.filesService.upload({
|
||||||
const url = await this.minioService.uploadFile(
|
resourceId: profileId,
|
||||||
objectName,
|
resource: LICENSE_RESOURCE,
|
||||||
file.buffer,
|
code,
|
||||||
file.mimetype,
|
file,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (gated) {
|
||||||
|
await this.stageLicenseChange(
|
||||||
|
company.id,
|
||||||
|
uploaded.map((r) => ({
|
||||||
|
profileId,
|
||||||
|
op: "add" as const,
|
||||||
|
fileId: r.id,
|
||||||
|
fileName: r.name,
|
||||||
|
})),
|
||||||
|
userId,
|
||||||
);
|
);
|
||||||
uploaded.push({
|
|
||||||
name: file.originalname,
|
|
||||||
url,
|
|
||||||
size: file.size,
|
|
||||||
mimeType: file.mimetype,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = [...(profile.businessLicenseFiles ?? []), ...uploaded];
|
return this.getProfileLicenseView(profileId, company.id);
|
||||||
await this.companyProfilesRepo.update(profileId, {
|
|
||||||
businessLicenseFiles: next,
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The business-license files stored on a single company profile. */
|
|
||||||
async listProfileLicenseFiles(
|
|
||||||
userId: string,
|
|
||||||
profileId: string,
|
|
||||||
): Promise<BusinessLicenseFile[]> {
|
|
||||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
|
||||||
return profile.businessLicenseFiles ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Onboarding documents stored on a company profile, fetched by profile id.
|
* Remove a license file. A staged (pending) file is withdrawn outright
|
||||||
* Internal helper (no ownership check) used when a booking reuses the active
|
* (soft-deleted, its `add` intent dropped). A live file on an Active company
|
||||||
* profile's onboarding documents. Returns [] when the profile is unknown.
|
* is kept and recorded as a `remove` intent for review; during onboarding it
|
||||||
|
* is deleted immediately.
|
||||||
|
*/
|
||||||
|
async removeProfileLicenseFile(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
fileId: string,
|
||||||
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||||
|
const record = await this.filesService.findById(fileId);
|
||||||
|
if (
|
||||||
|
record.resource !== LICENSE_RESOURCE ||
|
||||||
|
record.resourceId !== profileId
|
||||||
|
) {
|
||||||
|
throw new NotFoundException(`License file ${fileId} not found`);
|
||||||
|
}
|
||||||
|
const company = await this.findCompanyById(profile.companyId);
|
||||||
|
const gated = company.status === CompanyStatus.Active;
|
||||||
|
|
||||||
|
if (record.code === LICENSE_PENDING_CODE) {
|
||||||
|
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
|
||||||
|
await this.filesService.remove(fileId);
|
||||||
|
await this.withdrawLicenseIntent(company.id, fileId);
|
||||||
|
} else if (gated) {
|
||||||
|
await this.stageLicenseChange(
|
||||||
|
company.id,
|
||||||
|
[{ profileId, op: "remove", fileId, fileName: record.name }],
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.filesService.remove(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getProfileLicenseView(profileId, company.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a live license file with a freshly uploaded one — recorded as a
|
||||||
|
* `remove` of the old file plus an `add` of the new, so approval swaps them
|
||||||
|
* atomically. During onboarding the swap is applied immediately.
|
||||||
|
*/
|
||||||
|
async replaceProfileLicenseFile(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
fileId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||||
|
const old = await this.filesService.findById(fileId);
|
||||||
|
if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) {
|
||||||
|
throw new NotFoundException(`License file ${fileId} not found`);
|
||||||
|
}
|
||||||
|
const company = await this.findCompanyById(profile.companyId);
|
||||||
|
const gated = company.status === CompanyStatus.Active;
|
||||||
|
|
||||||
|
const created = await this.filesService.upload({
|
||||||
|
resourceId: profileId,
|
||||||
|
resource: LICENSE_RESOURCE,
|
||||||
|
code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (gated) {
|
||||||
|
await this.stageLicenseChange(
|
||||||
|
company.id,
|
||||||
|
[
|
||||||
|
{ profileId, op: "remove", fileId, fileName: old.name },
|
||||||
|
{ profileId, op: "add", fileId: created.id, fileName: created.name },
|
||||||
|
],
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.filesService.remove(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getProfileLicenseView(profileId, company.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** License files for one profile, with each file's review status resolved. */
|
||||||
|
async listProfileLicenseFiles(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||||
|
return this.getProfileLicenseView(profileId, profile.companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live license files for a profile, shaped for by-reference reuse (bookings /
|
||||||
|
* contracts snapshot these). No ownership check — internal callers only.
|
||||||
|
* Returns the raw stored URLs; pending (unapproved) files are excluded.
|
||||||
*/
|
*/
|
||||||
async getProfileOnboardingFiles(
|
async getProfileOnboardingFiles(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
): Promise<BusinessLicenseFile[]> {
|
): Promise<BusinessLicenseFile[]> {
|
||||||
const profile = await this.companyProfilesRepo.findById(profileId);
|
const records = await this.filesService.findByResource(
|
||||||
return profile?.businessLicenseFiles ?? [];
|
profileId,
|
||||||
|
LICENSE_RESOURCE,
|
||||||
|
);
|
||||||
|
return records
|
||||||
|
.filter((r) => r.code === LICENSE_CODE)
|
||||||
|
.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
url: r.url,
|
||||||
|
size: r.size,
|
||||||
|
mimeType: r.mimeType,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the review-aware license view for a set of profiles in one pass
|
||||||
|
* (single change-request lookup). Used to enrich company/profile responses.
|
||||||
|
*/
|
||||||
|
async assembleLicenseFilesByProfile(
|
||||||
|
companyId: string,
|
||||||
|
profileIds: string[],
|
||||||
|
): Promise<Record<string, ProfileLicenseFileView[]>> {
|
||||||
|
const pending =
|
||||||
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||||
|
const removeIds = new Set(
|
||||||
|
(pending?.documents?.licenseChanges ?? [])
|
||||||
|
.filter((c) => c.op === "remove")
|
||||||
|
.map((c) => c.fileId),
|
||||||
|
);
|
||||||
|
const result: Record<string, ProfileLicenseFileView[]> = {};
|
||||||
|
await Promise.all(
|
||||||
|
profileIds.map(async (pid) => {
|
||||||
|
result[pid] = await this.mapLicenseRecords(pid, removeIds);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-profile license view (fetches the company's pending request once). */
|
||||||
|
private async getProfileLicenseView(
|
||||||
|
profileId: string,
|
||||||
|
companyId: string,
|
||||||
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
|
const pending =
|
||||||
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||||
|
const removeIds = new Set(
|
||||||
|
(pending?.documents?.licenseChanges ?? [])
|
||||||
|
.filter((c) => c.op === "remove")
|
||||||
|
.map((c) => c.fileId),
|
||||||
|
);
|
||||||
|
return this.mapLicenseRecords(profileId, removeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mapLicenseRecords(
|
||||||
|
profileId: string,
|
||||||
|
pendingRemoveIds: Set<string>,
|
||||||
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
|
const records = await this.filesService.findByResource(
|
||||||
|
profileId,
|
||||||
|
LICENSE_RESOURCE,
|
||||||
|
);
|
||||||
|
return records
|
||||||
|
.filter(
|
||||||
|
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
|
||||||
|
)
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
size: r.size,
|
||||||
|
mimeType: r.mimeType,
|
||||||
|
status:
|
||||||
|
r.code === LICENSE_PENDING_CODE
|
||||||
|
? ("pending_add" as const)
|
||||||
|
: pendingRemoveIds.has(r.id)
|
||||||
|
? ("pending_remove" as const)
|
||||||
|
: ("live" as const),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open or append a pending change request recording license add/remove intents. */
|
||||||
|
private async stageLicenseChange(
|
||||||
|
companyId: string,
|
||||||
|
changes: LicenseChangeIntent[],
|
||||||
|
submittedBy?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (changes.length === 0) return;
|
||||||
|
const now = new Date();
|
||||||
|
const existing =
|
||||||
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||||
|
if (existing) {
|
||||||
|
const prev = existing.documents?.licenseChanges ?? [];
|
||||||
|
await this.changeRequestRepo.update(existing.id, {
|
||||||
|
documents: {
|
||||||
|
...existing.documents,
|
||||||
|
licenseChanges: [...prev, ...changes],
|
||||||
|
},
|
||||||
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||||
|
submittedAt: now,
|
||||||
|
note: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.changeRequestRepo.create({
|
||||||
|
companyId,
|
||||||
|
snapshot: {},
|
||||||
|
documents: { licenseChanges: changes },
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
|
submittedBy: submittedBy ?? null,
|
||||||
|
submittedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a staged license intent (add or remove) referencing `fileId` from the
|
||||||
|
* company's open request. If that empties the request entirely, delete it so
|
||||||
|
* the customer's settings page unlocks.
|
||||||
|
*/
|
||||||
|
private async withdrawLicenseIntent(
|
||||||
|
companyId: string,
|
||||||
|
fileId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing =
|
||||||
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||||
|
if (!existing) return;
|
||||||
|
const remaining = (existing.documents?.licenseChanges ?? []).filter(
|
||||||
|
(c) => c.fileId !== fileId,
|
||||||
|
);
|
||||||
|
const docs = existing.documents ?? {};
|
||||||
|
const stillHasWork =
|
||||||
|
remaining.length > 0 ||
|
||||||
|
(docs.documentFileIds?.length ?? 0) > 0 ||
|
||||||
|
Object.keys(existing.snapshot ?? {}).length > 0;
|
||||||
|
|
||||||
|
if (stillHasWork) {
|
||||||
|
await this.changeRequestRepo.update(existing.id, {
|
||||||
|
documents: { ...docs, licenseChanges: remaining },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.changeRequestRepo.softDelete(existing.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a request's staged license changes: promote adds, delete removes. */
|
||||||
|
private async applyLicenseChanges(
|
||||||
|
request: CompanyChangeRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const change of request.documents?.licenseChanges ?? []) {
|
||||||
|
if (change.op === "add") {
|
||||||
|
await this.filesService.setCode(change.fileId, LICENSE_CODE);
|
||||||
|
} else {
|
||||||
|
await this.filesService.remove(change.fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discard a rejected request's staged license uploads (adds only). */
|
||||||
|
private async discardLicenseChanges(
|
||||||
|
request: CompanyChangeRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const change of request.documents?.licenseChanges ?? []) {
|
||||||
|
if (change.op === "add") {
|
||||||
|
await this.filesService.remove(change.fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import {
|
||||||
|
ChangeRequestStatus,
|
||||||
|
CompanyChangeRequest,
|
||||||
|
} from "./entities/company-change-request.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CompanyChangeRequest)
|
||||||
|
repo: Repository<CompanyChangeRequest>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The company's current pending request, if any. */
|
||||||
|
async findPendingByCompanyId(
|
||||||
|
companyId: string,
|
||||||
|
): Promise<CompanyChangeRequest | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { companyId, status: ChangeRequestStatus.Pending },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The company's latest "open" request — pending (locks the customer) or the
|
||||||
|
* most recent rejected one (drives the reapply banner + prefill). Approved
|
||||||
|
* requests are terminal and ignored here.
|
||||||
|
*/
|
||||||
|
async findLatestOpenByCompanyId(
|
||||||
|
companyId: string,
|
||||||
|
): Promise<CompanyChangeRequest | null> {
|
||||||
|
const pending = await this.findPendingByCompanyId(companyId);
|
||||||
|
if (pending) return pending;
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { companyId, status: ChangeRequestStatus.Rejected },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<CompanyChangeRequest | null> {
|
||||||
|
return this.repository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCompanyId(companyId: string): Promise<CompanyChangeRequest[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { companyId },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
ChangeRequestStatus,
|
||||||
|
CompanyChangeRequest,
|
||||||
|
LicenseChangeIntent,
|
||||||
|
} from "../entities/company-change-request.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A staged profile change request. Used both by the portal (to lock the settings
|
||||||
|
* page, show the reviewer note, and prefill the proposed values) and by the
|
||||||
|
* backoffice review screen (to render the proposed-vs-current diff).
|
||||||
|
*/
|
||||||
|
export class ChangeRequestResponseDto {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
status: ChangeRequestStatus;
|
||||||
|
/** Proposed field values (Partial<UpdateProfileDto>) — the diff payload. */
|
||||||
|
snapshot: Record<string, any>;
|
||||||
|
documentFileIds: string[];
|
||||||
|
/** Staged business-license add/remove intents attached to this request. */
|
||||||
|
licenseChanges: LicenseChangeIntent[];
|
||||||
|
note: string | null;
|
||||||
|
submittedBy: string | null;
|
||||||
|
submittedAt: Date | null;
|
||||||
|
reviewedBy: string | null;
|
||||||
|
reviewedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(req: CompanyChangeRequest) {
|
||||||
|
this.id = req.id;
|
||||||
|
this.companyId = req.companyId;
|
||||||
|
this.status = req.status;
|
||||||
|
this.snapshot = req.snapshot ?? {};
|
||||||
|
this.documentFileIds = req.documents?.documentFileIds ?? [];
|
||||||
|
this.licenseChanges = req.documents?.licenseChanges ?? [];
|
||||||
|
this.note = req.note ?? null;
|
||||||
|
this.submittedBy = req.submittedBy ?? null;
|
||||||
|
this.submittedAt = req.submittedAt ?? null;
|
||||||
|
this.reviewedBy = req.reviewedBy ?? null;
|
||||||
|
this.reviewedAt = req.reviewedAt ?? null;
|
||||||
|
this.createdAt = req.createdAt;
|
||||||
|
this.updatedAt = req.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,43 @@
|
|||||||
import { Company } from '../entities/company.entity';
|
import { Company } from '../entities/company.entity';
|
||||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||||
|
import {
|
||||||
|
ChangeRequestStatus,
|
||||||
|
CompanyChangeRequest,
|
||||||
|
} from '../entities/company-change-request.entity';
|
||||||
import { ResponseCompanyDto } from './response-company.dto';
|
import { ResponseCompanyDto } from './response-company.dto';
|
||||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||||
|
|
||||||
export class CompanyInfoResponseDto {
|
export class CompanyInfoResponseDto {
|
||||||
profile: ResponseExternalProfileDto;
|
profile: ResponseExternalProfileDto;
|
||||||
company: ResponseCompanyDto;
|
company: ResponseCompanyDto;
|
||||||
|
/**
|
||||||
|
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
||||||
|
* settings + new-contract/booking creation disabled) and the reapply banner.
|
||||||
|
*/
|
||||||
|
review: {
|
||||||
|
status: 'pending' | 'rejected';
|
||||||
|
note: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
constructor(profile: ExternalProfile, company: Company) {
|
constructor(
|
||||||
|
profile: ExternalProfile,
|
||||||
|
company: Company,
|
||||||
|
changeRequest?: CompanyChangeRequest | null,
|
||||||
|
) {
|
||||||
this.profile = new ResponseExternalProfileDto(profile, company);
|
this.profile = new ResponseExternalProfileDto(profile, company);
|
||||||
this.company = new ResponseCompanyDto(company);
|
this.company = new ResponseCompanyDto(company);
|
||||||
|
|
||||||
|
const open =
|
||||||
|
changeRequest &&
|
||||||
|
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||||
|
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||||
|
? changeRequest
|
||||||
|
: null;
|
||||||
|
this.review = open
|
||||||
|
? {
|
||||||
|
status: open.status as 'pending' | 'rejected',
|
||||||
|
note: open.note ?? null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Company } from '../entities/company.entity';
|
import { Company } from '../entities/company.entity';
|
||||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||||
|
import {
|
||||||
|
ChangeRequestStatus,
|
||||||
|
CompanyChangeRequest,
|
||||||
|
} from '../entities/company-change-request.entity';
|
||||||
import { ResponseCompanyProfileDto } from './response-company.dto';
|
import { ResponseCompanyProfileDto } from './response-company.dto';
|
||||||
|
|
||||||
export class ProfileResponseDto {
|
export class ProfileResponseDto {
|
||||||
@@ -48,7 +52,20 @@ export class ProfileResponseDto {
|
|||||||
|
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
|
||||||
constructor(profile: ExternalProfile, company: Company) {
|
/**
|
||||||
|
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||||
|
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
||||||
|
* proposed values from `pendingChanges` so the customer can amend & resubmit.
|
||||||
|
*/
|
||||||
|
reviewStatus: "pending" | "rejected" | null;
|
||||||
|
reviewNote: string | null;
|
||||||
|
pendingChanges: Record<string, any> | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
profile: ExternalProfile,
|
||||||
|
company: Company,
|
||||||
|
changeRequest?: CompanyChangeRequest | null,
|
||||||
|
) {
|
||||||
this.companyId = company.id;
|
this.companyId = company.id;
|
||||||
this.companyName = company.name;
|
this.companyName = company.name;
|
||||||
this.companyType = company.type;
|
this.companyType = company.type;
|
||||||
@@ -92,5 +109,20 @@ export class ProfileResponseDto {
|
|||||||
this.poaEmail = attrs.poaEmail ?? null;
|
this.poaEmail = attrs.poaEmail ?? null;
|
||||||
this.poaLocation = attrs.poaLocation ?? null;
|
this.poaLocation = attrs.poaLocation ?? null;
|
||||||
this.poaAddress = attrs.poaAddress ?? null;
|
this.poaAddress = attrs.poaAddress ?? null;
|
||||||
|
|
||||||
|
const openReview =
|
||||||
|
changeRequest &&
|
||||||
|
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||||
|
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||||
|
? changeRequest
|
||||||
|
: null;
|
||||||
|
this.reviewStatus =
|
||||||
|
openReview?.status === ChangeRequestStatus.Pending
|
||||||
|
? "pending"
|
||||||
|
: openReview?.status === ChangeRequestStatus.Rejected
|
||||||
|
? "rejected"
|
||||||
|
: null;
|
||||||
|
this.reviewNote = openReview?.note ?? null;
|
||||||
|
this.pendingChanges = openReview?.snapshot ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsString, MaxLength, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class RejectChangeRequestDto {
|
||||||
|
/** Why the proposed changes were declined — shown to the customer so they can fix and resubmit. */
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(2000)
|
||||||
|
note!: string;
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
CompanyNationality,
|
CompanyNationality,
|
||||||
} from '../entities/company.entity';
|
} from '../entities/company.entity';
|
||||||
import {
|
import {
|
||||||
BusinessLicenseFile,
|
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
|
ProfileLicenseFileView,
|
||||||
} from '../entities/company-profile.entity';
|
} from '../entities/company-profile.entity';
|
||||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||||
|
|
||||||
@@ -18,9 +18,15 @@ export class ResponseCompanyProfileDto {
|
|||||||
status: string;
|
status: string;
|
||||||
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */
|
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */
|
||||||
businessLicense?: string | null;
|
businessLicense?: string | null;
|
||||||
/** Business-license documents stored on the profile (multi-file). */
|
/**
|
||||||
licenseFiles: BusinessLicenseFile[];
|
* Business-license documents (FileRecord-backed) with review state. Left empty
|
||||||
|
* by the constructor and populated asynchronously by the controller, since the
|
||||||
|
* files and their pending-change status require DB lookups.
|
||||||
|
*/
|
||||||
|
licenseFiles: ProfileLicenseFileView[];
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
|
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
||||||
|
reviewNote?: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
@@ -31,8 +37,9 @@ export class ResponseCompanyProfileDto {
|
|||||||
this.reference = profile.reference ?? '';
|
this.reference = profile.reference ?? '';
|
||||||
this.status = profile.status;
|
this.status = profile.status;
|
||||||
this.businessLicense = profile.businessLicense;
|
this.businessLicense = profile.businessLicense;
|
||||||
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
this.licenseFiles = [];
|
||||||
this.attributes = profile.attributes;
|
this.attributes = profile.attributes;
|
||||||
|
this.reviewNote = profile.reviewNote ?? null;
|
||||||
this.createdAt = profile.createdAt;
|
this.createdAt = profile.createdAt;
|
||||||
this.updatedAt = profile.updatedAt;
|
this.updatedAt = profile.updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsIn } from "class-validator";
|
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
import { ProfileStatus } from "../entities/company-profile.entity";
|
import { ProfileStatus } from "../entities/company-profile.entity";
|
||||||
|
|
||||||
export class UpdateCompanyProfileStatusDto {
|
export class UpdateCompanyProfileStatusDto {
|
||||||
@ApiProperty({ enum: ProfileStatus })
|
@ApiProperty({ enum: ProfileStatus })
|
||||||
@IsIn(Object.values(ProfileStatus))
|
@IsIn(Object.values(ProfileStatus))
|
||||||
status!: ProfileStatus;
|
status!: ProfileStatus;
|
||||||
|
|
||||||
|
/** Reviewer note — required in practice when rejecting so the customer knows why. */
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
import { Company } from "./company.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle of a customer's proposed profile change. Edits made on the portal
|
||||||
|
* settings page by an already-approved company are staged here (not written to
|
||||||
|
* the live Company row) until a backoffice reviewer approves — at which point
|
||||||
|
* the snapshot is applied — or rejects with a note, after which the customer can
|
||||||
|
* amend and resubmit.
|
||||||
|
*/
|
||||||
|
export enum ChangeRequestStatus {
|
||||||
|
Pending = "pending",
|
||||||
|
Approved = "approved",
|
||||||
|
Rejected = "rejected",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single staged business-license change on one company profile, awaiting
|
||||||
|
* review. `add` → a new file was uploaded under the pending code and becomes
|
||||||
|
* live on approval; `remove` → an existing live file is deleted on approval.
|
||||||
|
* A "replace" is recorded as a `remove` of the old file plus an `add` of the
|
||||||
|
* new one. `fileId` is the FileRecord id the op targets.
|
||||||
|
*/
|
||||||
|
export interface LicenseChangeIntent {
|
||||||
|
profileId: string;
|
||||||
|
op: "add" | "remove";
|
||||||
|
fileId: string;
|
||||||
|
/** File name, snapshotted for the backoffice review screen. */
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File references staged alongside a change request (documents/licenses). */
|
||||||
|
export interface ChangeRequestDocuments {
|
||||||
|
/** FileRecord ids uploaded against the company while this request was open. */
|
||||||
|
documentFileIds?: string[];
|
||||||
|
/** Staged per-profile business-license add/remove intents. */
|
||||||
|
licenseChanges?: LicenseChangeIntent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: "freight", name: "company_change_request" })
|
||||||
|
@Index(["companyId"])
|
||||||
|
@Index(["status"])
|
||||||
|
export class CompanyChangeRequest extends BaseEntity {
|
||||||
|
@Column({ name: "company_id", type: "uuid" })
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company, { onDelete: "CASCADE" })
|
||||||
|
@JoinColumn({ name: "company_id" })
|
||||||
|
company?: Company;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proposed profile field values, shaped as `Partial<UpdateProfileDto>`. Covers
|
||||||
|
* the Company / Contact / General Manager / Power-of-Attorney tabs (contact/GM/
|
||||||
|
* PoA fields land in `Company.attributes` on approval).
|
||||||
|
*/
|
||||||
|
@Column({ name: "snapshot", type: "jsonb" })
|
||||||
|
snapshot!: Record<string, any>;
|
||||||
|
|
||||||
|
/** Staged document/license file references (see {@link ChangeRequestDocuments}). */
|
||||||
|
@Column({ name: "documents", type: "jsonb", nullable: true })
|
||||||
|
documents?: ChangeRequestDocuments | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "status",
|
||||||
|
type: "varchar",
|
||||||
|
length: 20,
|
||||||
|
default: ChangeRequestStatus.Pending,
|
||||||
|
})
|
||||||
|
status!: ChangeRequestStatus;
|
||||||
|
|
||||||
|
/** Backoffice reviewer's rejection note. */
|
||||||
|
@Column({ name: "note", type: "text", nullable: true })
|
||||||
|
note?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "submitted_by", type: "uuid", nullable: true })
|
||||||
|
submittedBy?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "submitted_at", type: "timestamptz", nullable: true })
|
||||||
|
submittedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
|
||||||
|
reviewedBy?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||||
|
reviewedAt?: Date | null;
|
||||||
|
}
|
||||||
@@ -13,11 +13,17 @@ export enum ProfileType {
|
|||||||
export enum ProfileStatus {
|
export enum ProfileStatus {
|
||||||
Active = "active",
|
Active = "active",
|
||||||
Pending = "pending",
|
Pending = "pending",
|
||||||
|
/** Reviewer declined the role; carries a note. Customer can reapply → Pending. */
|
||||||
|
Rejected = "rejected",
|
||||||
Suspended = "suspended",
|
Suspended = "suspended",
|
||||||
Blacklisted = "blacklisted",
|
Blacklisted = "blacklisted",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A business-license document stored directly on the company profile. */
|
/**
|
||||||
|
* @deprecated Legacy inline shape. Business-license files now live in the
|
||||||
|
* FileRecord model (`freight.files`, resource `company_profiles`). Kept only for
|
||||||
|
* the by-reference reuse shape consumed by bookings/contracts snapshots.
|
||||||
|
*/
|
||||||
export interface BusinessLicenseFile {
|
export interface BusinessLicenseFile {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -25,6 +31,19 @@ export interface BusinessLicenseFile {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A business-license file plus its change-review state, surfaced to clients. */
|
||||||
|
export interface ProfileLicenseFileView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
/**
|
||||||
|
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
|
||||||
|
* `pending_remove` — live but flagged for deletion on approval.
|
||||||
|
*/
|
||||||
|
status: "live" | "pending_add" | "pending_remove";
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "company_profiles" })
|
@Entity({ schema: "freight", name: "company_profiles" })
|
||||||
@Index(["reference"], { unique: true })
|
@Index(["reference"], { unique: true })
|
||||||
@Index(["type"])
|
@Index(["type"])
|
||||||
@@ -80,4 +99,14 @@ export class CompanyProfile extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
|
|
||||||
|
/** Reviewer's note when the role is Rejected (cleared on reapply). */
|
||||||
|
@Column({ name: "review_note", type: "text", nullable: true })
|
||||||
|
reviewNote?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
|
||||||
|
reviewedBy?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||||
|
reviewedAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { YardCountry } from '@edr/types';
|
|||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
@@ -326,10 +326,20 @@ export class ContractsService {
|
|||||||
companyProfileId: string | null,
|
companyProfileId: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!companyProfileId) return;
|
if (!companyProfileId) return;
|
||||||
const profile = await this.dataSource
|
// Business-license files are FileRecords (resource "company_profiles"); carry
|
||||||
.getRepository(CompanyProfile)
|
// the live ones by reference. Staged/pending uploads are excluded by code.
|
||||||
.findOne({ where: { id: companyProfileId } });
|
const records = await this.filesService.findByResource(
|
||||||
const docs = profile?.businessLicenseFiles ?? [];
|
companyProfileId,
|
||||||
|
'company_profiles',
|
||||||
|
);
|
||||||
|
const docs = records
|
||||||
|
.filter((r) => r.code === 'business_license')
|
||||||
|
.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
url: r.url,
|
||||||
|
size: r.size,
|
||||||
|
mimeType: r.mimeType,
|
||||||
|
}));
|
||||||
if (docs.length === 0) return;
|
if (docs.length === 0) return;
|
||||||
|
|
||||||
const slug = (name: string) =>
|
const slug = (name: string) =>
|
||||||
|
|||||||
@@ -9,14 +9,19 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
Matches,
|
||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
/** One physical container under a booking line — entered at booking time. */
|
/** One physical container under a booking line — entered at booking time. */
|
||||||
export class CreateContainerUnitDto {
|
export class CreateContainerUnitDto {
|
||||||
@ApiProperty()
|
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value))
|
||||||
|
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||||
|
message: 'containerNumber must match ISO container format, e.g. ABCD1234567',
|
||||||
|
})
|
||||||
containerNumber!: string;
|
containerNumber!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
|
|||||||
@@ -124,6 +124,15 @@ export class FilesService {
|
|||||||
await this.filesRepository.softDelete(id);
|
await this.filesRepository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-slot a stored file under a new `code` (e.g. promote a staged
|
||||||
|
* `business_license_pending` file to the live `business_license` code once a
|
||||||
|
* change request is approved). Bytes and URL are untouched.
|
||||||
|
*/
|
||||||
|
async setCode(id: string, code: string): Promise<void> {
|
||||||
|
await this.filesRepository.update(id, { code });
|
||||||
|
}
|
||||||
|
|
||||||
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
||||||
return this.filesRepository.findByResource(resourceId, resource);
|
return this.filesRepository.findByResource(resourceId, resource);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { GpsTrackingService } from './gps-tracking.service';
|
import { GpsTrackingService } from './gps-tracking.service';
|
||||||
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
|
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
|
||||||
|
|
||||||
@ApiTags('gps-tracking')
|
@ApiTags('gps-tracking')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('gps')
|
@Controller('gps')
|
||||||
@FleetView()
|
@BookingStaff(FREIGHT_PERMS.tracking.view)
|
||||||
export class GpsTrackingController {
|
export class GpsTrackingController {
|
||||||
constructor(private readonly gps: GpsTrackingService) {}
|
constructor(private readonly gps: GpsTrackingService) {}
|
||||||
|
|
||||||
@@ -44,21 +45,21 @@ export class GpsTrackingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('devices')
|
@Post('devices')
|
||||||
@FleetManage()
|
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||||
@ApiOperation({ summary: 'Register a GPS tracker' })
|
@ApiOperation({ summary: 'Register a GPS tracker' })
|
||||||
register(@Body() dto: RegisterDeviceDto) {
|
register(@Body() dto: RegisterDeviceDto) {
|
||||||
return this.gps.registerDevice(dto);
|
return this.gps.registerDevice(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('devices/:id')
|
@Patch('devices/:id')
|
||||||
@FleetManage()
|
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||||
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
|
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
|
||||||
return this.gps.updateDevice(id, dto);
|
return this.gps.updateDevice(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('devices/:id')
|
@Delete('devices/:id')
|
||||||
@FleetManage()
|
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||||
@ApiOperation({ summary: 'Delete a GPS tracker' })
|
@ApiOperation({ summary: 'Delete a GPS tracker' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.gps.removeDevice(id);
|
return this.gps.removeDevice(id);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
|
||||||
|
* company's phone/email and sends the message over both channels, swallowing
|
||||||
|
* per-channel failures so a missing provider never breaks the caller's flow.
|
||||||
|
*/
|
||||||
|
export async function sendCompanyChannels(
|
||||||
|
dataSource: DataSource,
|
||||||
|
notifications: NotificationsService,
|
||||||
|
companyId: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const [contact]: Array<{ phone: string | null; email: string | null }> =
|
||||||
|
await dataSource.query(
|
||||||
|
`SELECT COALESCE(phone, etrade_phone) AS phone, email
|
||||||
|
FROM freight.companies
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[companyId],
|
||||||
|
);
|
||||||
|
if (contact?.phone) {
|
||||||
|
try {
|
||||||
|
await notifications.directSend('sms', contact.phone, message);
|
||||||
|
} catch {
|
||||||
|
/* best-effort: SMS provider unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contact?.email) {
|
||||||
|
try {
|
||||||
|
await notifications.directSend('email', contact.email, message);
|
||||||
|
} catch {
|
||||||
|
/* best-effort: email provider unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,24 @@ export class BookingNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Train carrying the booking departed — dispatched origin → destination. */
|
||||||
|
dispatched(b: Booking, origin: string | null, destination: string | null): void {
|
||||||
|
const msg =
|
||||||
|
`Your booking ${b.reference ?? b.id} has been dispatched` +
|
||||||
|
`${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`;
|
||||||
|
void this.notifyContact(b, msg, 'DISPATCHED');
|
||||||
|
this.inApp(b, 'Shipment dispatched', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Train carrying the booking arrived at destination. */
|
||||||
|
arrived(b: Booking, origin: string | null, destination: string | null): void {
|
||||||
|
const msg =
|
||||||
|
`Your booking ${b.reference ?? b.id} has arrived` +
|
||||||
|
`${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`;
|
||||||
|
void this.notifyContact(b, msg, 'ARRIVED');
|
||||||
|
this.inApp(b, 'Shipment arrived', msg);
|
||||||
|
}
|
||||||
|
|
||||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
{
|
{
|
||||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||||
} as never, // bookingJourneyService
|
} as never, // bookingJourneyService
|
||||||
|
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultFleetWagons = [
|
const defaultFleetWagons = [
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.d
|
|||||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||||
import { type BookingWindowConfig } from './booking-window.config';
|
import { type BookingWindowConfig } from './booking-window.config';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
import {
|
import {
|
||||||
buildCappedWagonPlan,
|
buildCappedWagonPlan,
|
||||||
computeFleetAvailability,
|
computeFleetAvailability,
|
||||||
@@ -281,10 +282,38 @@ export class TrainSchedulingService {
|
|||||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||||
private readonly bookingJourneyService: BookingJourneyService,
|
private readonly bookingJourneyService: BookingJourneyService,
|
||||||
|
private readonly bookingNotifier: BookingNotifierService,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
private readonly configService?: ConfigService,
|
private readonly configService?: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify each booking's customer that their shipment was dispatched / arrived,
|
||||||
|
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
|
||||||
|
*/
|
||||||
|
private async notifyScheduleBookings(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
event: 'dispatched' | 'arrived',
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean);
|
||||||
|
if (!ids.length) return;
|
||||||
|
const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null;
|
||||||
|
const destination =
|
||||||
|
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null;
|
||||||
|
const bookings = await this.dataSource.getRepository(Booking).find({
|
||||||
|
where: { id: In(ids) },
|
||||||
|
relations: { company: true },
|
||||||
|
});
|
||||||
|
for (const b of bookings) {
|
||||||
|
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
|
||||||
|
else this.bookingNotifier.arrived(b, origin, destination);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete customer-tracking clearance milestones for every booking on a
|
* Complete customer-tracking clearance milestones for every booking on a
|
||||||
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
|
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
|
||||||
@@ -1551,6 +1580,7 @@ export class TrainSchedulingService {
|
|||||||
{ originYardId: schedule.originStationId },
|
{ originYardId: schedule.originStationId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
void this.notifyScheduleBookings(schedule, 'dispatched');
|
||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2556,6 +2586,7 @@ export class TrainSchedulingService {
|
|||||||
{ destinationYardId: schedule.destinationStationId },
|
{ destinationYardId: schedule.destinationStationId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
void this.notifyScheduleBookings(schedule, 'arrived');
|
||||||
|
|
||||||
const detail = await this.getTrainScheduleById(scheduleId);
|
const detail = await this.getTrainScheduleById(scheduleId);
|
||||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class CreateWarehouseYardDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
capacityContainers?: number;
|
capacityContainers?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
@ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class CreateWarehouseZoneDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
capacityContainers?: number;
|
capacityContainers?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
@ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
|
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
|
||||||
|
|
||||||
export class CreateWarehouseDto {
|
export class CreateWarehouseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -47,7 +47,7 @@ export class CreateWarehouseDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
capacityContainers?: number;
|
capacityContainers?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
@ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@@ -58,4 +58,9 @@ export class CreateWarehouseDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
maxVolume?: number;
|
maxVolume?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(WAREHOUSE_STATUSES)
|
||||||
|
status?: WarehouseStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export class LoadInventoryDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
wagonId!: string;
|
wagonId!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' })
|
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional explicit storage location. When warehouse/yard/zone are all provided,
|
||||||
|
* the item is stored there directly; otherwise store() falls back to the
|
||||||
|
* allocation-rule / capacity-balanced auto pick.
|
||||||
|
*/
|
||||||
|
export class StoreInventoryDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
warehouseId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
yardId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
zoneId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
performedBy?: string;
|
||||||
|
}
|
||||||
@@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
|||||||
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
||||||
UNLOADED_AT_DJIBOUTI_PORT: [],
|
UNLOADED_AT_DJIBOUTI_PORT: [],
|
||||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||||
STORED: ['RESERVED'],
|
// Reserve is retired from the operator flow — a stored export item advances
|
||||||
|
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
|
||||||
|
STORED: ['RESERVED', 'READY_FOR_LOADING'],
|
||||||
RESERVED: ['READY_FOR_LOADING'],
|
RESERVED: ['READY_FOR_LOADING'],
|
||||||
READY_FOR_LOADING: ['LOADED'],
|
READY_FOR_LOADING: ['LOADED'],
|
||||||
LOADED: ['DISPATCHED'],
|
LOADED: ['DISPATCHED'],
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||||
|
|
||||||
import { BookingHandover } from './entities/booking-handover.entity';
|
import { BookingHandover } from './entities/booking-handover.entity';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import handover records. A booking has one handover per truck (single truck ⇒
|
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||||
@@ -13,7 +17,35 @@ import { BookingHandover } from './entities/booking-handover.entity';
|
|||||||
export class HandoverService {
|
export class HandoverService {
|
||||||
private readonly logger = new Logger(HandoverService.name);
|
private readonly logger = new Logger(HandoverService.name);
|
||||||
|
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Tell the customer a handover is ready and needs their signature. */
|
||||||
|
private async notifySignNeeded(bookingId: string, reference: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
||||||
|
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!b?.companyId) return;
|
||||||
|
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: b.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.DOCUMENT_ACTION,
|
||||||
|
title: 'Handover — signature needed',
|
||||||
|
body,
|
||||||
|
link: `/bookings/${bookingId}`,
|
||||||
|
data: { bookingId, reference },
|
||||||
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
list(bookingId: string): Promise<BookingHandover[]> {
|
list(bookingId: string): Promise<BookingHandover[]> {
|
||||||
return this.dataSource.getRepository(BookingHandover).find({
|
return this.dataSource.getRepository(BookingHandover).find({
|
||||||
@@ -22,6 +54,32 @@ export class HandoverService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the customer to sign the booking's handover. Ensures a handover exists
|
||||||
|
* (creates a booking-level self-haul one if none yet), then fires the
|
||||||
|
* sign-needed notification (in-app + SMS + email). Idempotent to re-send.
|
||||||
|
*/
|
||||||
|
async requestSignature(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingHandover);
|
||||||
|
const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } });
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
// No handover yet (truck not arrived): create a booking-level one so the
|
||||||
|
// customer has something to sign. ensureForArrivedTruck notifies on create.
|
||||||
|
const created = await this.ensureForArrivedTruck(bookingId, {});
|
||||||
|
return { notified: true, reference: created.reference, alreadySigned: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsigned = existing.find((h) => !h.signedAt);
|
||||||
|
if (!unsigned) {
|
||||||
|
return { notified: false, reference: existing[0].reference, alreadySigned: true };
|
||||||
|
}
|
||||||
|
await this.notifySignNeeded(bookingId, unsigned.reference);
|
||||||
|
return { notified: true, reference: unsigned.reference, alreadySigned: false };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Self-haul: ensure a handover exists for a customer truck that just arrived.
|
* Self-haul: ensure a handover exists for a customer truck that just arrived.
|
||||||
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
|
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
|
||||||
@@ -54,6 +112,7 @@ export class HandoverService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
|
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
|
||||||
|
void this.notifySignNeeded(bookingId, reference);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
|
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||||
@@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WarehouseInspectionService {
|
export class WarehouseInspectionService {
|
||||||
|
private readonly logger = new Logger(WarehouseInspectionService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly inspectionRepository: WarehouseInspectionRepository,
|
private readonly inspectionRepository: WarehouseInspectionRepository,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
|
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
|
||||||
@@ -44,7 +53,7 @@ export class WarehouseInspectionService {
|
|||||||
expectedWeight: expected,
|
expectedWeight: expected,
|
||||||
actualWeight: actual,
|
actualWeight: actual,
|
||||||
weightLoss,
|
weightLoss,
|
||||||
weightLossUnit: weightLoss !== null ? 'kg' : null,
|
weightLossUnit: weightLoss !== null ? 't' : null,
|
||||||
hasMissingItems: dto.hasMissingItems ?? false,
|
hasMissingItems: dto.hasMissingItems ?? false,
|
||||||
missingItemsDescription: dto.missingItemsDescription ?? null,
|
missingItemsDescription: dto.missingItemsDescription ?? null,
|
||||||
remarks: dto.remarks ?? null,
|
remarks: dto.remarks ?? null,
|
||||||
@@ -83,8 +92,10 @@ export class WarehouseInspectionService {
|
|||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.booking_id AS "bookingId",
|
`SELECT inv.booking_id AS "bookingId",
|
||||||
b.reference AS "bookingReference",
|
b.reference AS "bookingReference",
|
||||||
|
b.company_id AS "companyId",
|
||||||
b.trade_direction AS "tradeDirection",
|
b.trade_direction AS "tradeDirection",
|
||||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||||
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||||
@@ -105,6 +116,36 @@ export class WarehouseInspectionService {
|
|||||||
|
|
||||||
if (row.bookingReference && hasLastMile) {
|
if (row.bookingReference && hasLastMile) {
|
||||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||||
|
} else if (!hasLastMile && !row.customerTruckAssignedAt) {
|
||||||
|
// Self-haul import: goods are pickup-ready but no collection truck is
|
||||||
|
// assigned yet — nudge the customer to assign one from the portal.
|
||||||
|
void this.notifyTruckAssignmentNeeded(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */
|
||||||
|
private async notifyTruckAssignmentNeeded(row: {
|
||||||
|
bookingId?: string | null;
|
||||||
|
bookingReference?: string | null;
|
||||||
|
companyId?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!row.companyId || !row.bookingId) return;
|
||||||
|
const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`;
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: row.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title: 'Assign a truck for pickup',
|
||||||
|
body,
|
||||||
|
link: `/bookings/${row.bookingId}`,
|
||||||
|
data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' },
|
||||||
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
|||||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||||
|
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||||
@@ -267,9 +268,9 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/store')
|
@Post(':id/store')
|
||||||
@ApiOperation({ summary: 'Mark received inventory as STORED' })
|
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||||
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
||||||
return this.inventoryService.store(id, performedBy);
|
return this.inventoryService.store(id, dto.performedBy, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/ready-for-loading')
|
@Post(':id/ready-for-loading')
|
||||||
@@ -354,12 +355,34 @@ export class WarehouseInventoryController {
|
|||||||
return this.handoverService.list(bookingId);
|
return this.handoverService.list(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/request-handover-signature')
|
||||||
|
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||||
|
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.handoverService.requestSignature(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('bookings/:bookingId/handover-document')
|
||||||
|
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
||||||
|
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||||
|
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
return res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/container-items')
|
@Get('bookings/:bookingId/container-items')
|
||||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||||
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
return this.inventoryService.containerItems(bookingId);
|
return this.inventoryService.containerItems(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('bookings/:bookingId/container-weights')
|
||||||
|
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
|
||||||
|
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/deliver')
|
@Post(':id/deliver')
|
||||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
|
|||||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||||
@@ -39,6 +40,8 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
|||||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||||
import { HandoverService } from './handover.service';
|
import { HandoverService } from './handover.service';
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
|
||||||
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||||
@@ -356,12 +359,14 @@ export interface ImportUnloadedRow {
|
|||||||
customerTruckType: string | null;
|
customerTruckType: string | null;
|
||||||
customerTruckContainerNumber: string | null;
|
customerTruckContainerNumber: string | null;
|
||||||
customerTruckAssignedAt: string | null;
|
customerTruckAssignedAt: string | null;
|
||||||
|
hasAssignedTruck: boolean;
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseOrderReference: string | null;
|
releaseOrderReference: string | null;
|
||||||
handoverDocumentReference: string | null;
|
handoverDocumentReference: string | null;
|
||||||
handoverDocumentDate: string | null;
|
handoverDocumentDate: string | null;
|
||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -383,8 +388,41 @@ export class WarehouseInventoryService {
|
|||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly signatures: SignaturesService,
|
private readonly signatures: SignaturesService,
|
||||||
private readonly handover: HandoverService,
|
private readonly handover: HandoverService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a self-haul booking (no EDR first/last mile) is received to the warehouse
|
||||||
|
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
||||||
|
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
||||||
|
*/
|
||||||
|
private async notifyTruckAssignmentNeeded(booking: {
|
||||||
|
companyId?: string | null;
|
||||||
|
reference?: string | null;
|
||||||
|
hasFirstMile?: boolean;
|
||||||
|
hasLastMile?: boolean;
|
||||||
|
customerTruckAssignedAt?: string | null;
|
||||||
|
}, bookingId: string): Promise<void> {
|
||||||
|
if (!booking.companyId) return;
|
||||||
|
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
|
||||||
|
if (booking.customerTruckAssignedAt) return; // already assigned
|
||||||
|
const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`;
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title: 'Assign a truck for pickup',
|
||||||
|
body,
|
||||||
|
link: `/bookings/${bookingId}`,
|
||||||
|
data: { bookingId, action: 'ASSIGN_TRUCK' },
|
||||||
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch 6 — final terminal release / gate clearance.
|
* Batch 6 — final terminal release / gate clearance.
|
||||||
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
|
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
|
||||||
@@ -866,7 +904,10 @@ export class WarehouseInventoryService {
|
|||||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||||
b.customer_truck_type AS "customerTruckType",
|
b.customer_truck_type AS "customerTruckType",
|
||||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
|
b.company_id AS "companyId",
|
||||||
|
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||||
|
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
||||||
FROM freight.bookings b
|
FROM freight.bookings b
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||||
@@ -1002,6 +1043,7 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
result.receivedCount += 1;
|
result.receivedCount += 1;
|
||||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||||
|
void this.notifyTruckAssignmentNeeded(booking, bookingId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1301,12 +1343,18 @@ export class WarehouseInventoryService {
|
|||||||
b.customer_truck_type AS "customerTruckType",
|
b.customer_truck_type AS "customerTruckType",
|
||||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
|
(b.customer_truck_assigned_at IS NOT NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM freight.last_mile lm
|
||||||
|
WHERE lm.booking_id = b.id
|
||||||
|
AND lm.vehicle_id IS NOT NULL
|
||||||
|
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
|
||||||
inv.status AS "currentStatus",
|
inv.status AS "currentStatus",
|
||||||
inv.release_date AS "releaseDate",
|
inv.release_date AS "releaseDate",
|
||||||
inv.release_order_reference AS "releaseOrderReference",
|
inv.release_order_reference AS "releaseOrderReference",
|
||||||
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
||||||
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
||||||
inv.delivered_at AS "deliveredAt",
|
inv.delivered_at AS "deliveredAt",
|
||||||
|
inv.notes AS "notes",
|
||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
dy.country AS "destinationCountry"
|
dy.country AS "destinationCountry"
|
||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
@@ -2020,7 +2068,7 @@ export class WarehouseInventoryService {
|
|||||||
activityType: 'INVENTORY_RECEIVED',
|
activityType: 'INVENTORY_RECEIVED',
|
||||||
inventoryId: saved.id,
|
inventoryId: saved.id,
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`,
|
description: `GRN ${grnNumber}: received ${weight}t via truck ${truckEntrance.truckPlateNumber}`,
|
||||||
performedBy: dto.performedBy,
|
performedBy: dto.performedBy,
|
||||||
},
|
},
|
||||||
manager,
|
manager,
|
||||||
@@ -2104,13 +2152,30 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
// ── Lifecycle transitions ────────────────────────────────────────────────
|
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||||||
|
|
||||||
async store(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
async store(
|
||||||
|
id: string,
|
||||||
|
performedBy?: string,
|
||||||
|
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
|
||||||
|
): Promise<WarehouseInventory> {
|
||||||
const item = await this.findById(id);
|
const item = await this.findById(id);
|
||||||
this.assertTransition(item.status, 'STORED');
|
this.assertTransition(item.status, 'STORED');
|
||||||
|
|
||||||
|
// Explicit location wins when the operator picked warehouse + yard + zone;
|
||||||
|
// otherwise fall back to the allocation-rule / capacity-balanced auto pick.
|
||||||
|
const manualLocation =
|
||||||
|
chosen?.warehouseId && chosen?.yardId && chosen?.zoneId
|
||||||
|
? {
|
||||||
|
warehouseId: chosen.warehouseId,
|
||||||
|
yardId: chosen.yardId,
|
||||||
|
zoneId: chosen.zoneId,
|
||||||
|
path: undefined as string | undefined,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
const criteria = await this.getInventoryAllocationCriteria(item);
|
const criteria = await this.getInventoryAllocationCriteria(item);
|
||||||
const ruleLocation = await this.allocation.resolveLocation(criteria);
|
const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria);
|
||||||
const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
const location =
|
||||||
|
manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
||||||
|
|
||||||
if (!location) {
|
if (!location) {
|
||||||
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
|
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
|
||||||
@@ -2154,18 +2219,19 @@ export class WarehouseInventoryService {
|
|||||||
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
|
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const storedReason = manualLocation
|
||||||
|
? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
|
||||||
|
: ruleLocation?.rule
|
||||||
|
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
||||||
|
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
|
||||||
|
|
||||||
await manager.getRepository(WarehouseInventory).update(id, {
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
status: 'STORED',
|
status: 'STORED',
|
||||||
storedAt: new Date(),
|
storedAt: new Date(),
|
||||||
warehouseId: location.warehouseId,
|
warehouseId: location.warehouseId,
|
||||||
yardId: location.yardId,
|
yardId: location.yardId,
|
||||||
zoneId: location.zoneId,
|
zoneId: location.zoneId,
|
||||||
notes: this.appendNote(
|
notes: this.appendNote(locked.notes, storedReason),
|
||||||
locked.notes,
|
|
||||||
ruleLocation?.rule
|
|
||||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
|
||||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
@@ -2173,9 +2239,7 @@ export class WarehouseInventoryService {
|
|||||||
activityType: 'INVENTORY_STORED',
|
activityType: 'INVENTORY_STORED',
|
||||||
inventoryId: id,
|
inventoryId: id,
|
||||||
warehouseId: location.warehouseId,
|
warehouseId: location.warehouseId,
|
||||||
description: ruleLocation?.rule
|
description: storedReason.replace(/^Stored/, 'Inventory stored'),
|
||||||
? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}`
|
|
||||||
: `Inventory stored at ${location.path ?? 'assigned yard/zone'}`,
|
|
||||||
performedBy,
|
performedBy,
|
||||||
},
|
},
|
||||||
manager,
|
manager,
|
||||||
@@ -2294,6 +2358,26 @@ export class WarehouseInventoryService {
|
|||||||
'Customer must sign the handover before the exit paper can be generated',
|
'Customer must sign the handover before the exit paper can be generated',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||||
|
// total VGM cargo weight of the containers selected as loaded on it.
|
||||||
|
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
|
||||||
|
const selected = dto.containerNumber
|
||||||
|
.split(/[,;\n]+/)
|
||||||
|
.map((n) => n.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (selected.length) {
|
||||||
|
const weights = await this.bookingContainerWeights(item.bookingId);
|
||||||
|
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
|
||||||
|
const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0);
|
||||||
|
const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3));
|
||||||
|
if (expected > 0 && Math.abs(computedNet - expected) > 0.001) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const releaseDate = isTruckLeaving
|
const releaseDate = isTruckLeaving
|
||||||
@@ -2519,15 +2603,17 @@ export class WarehouseInventoryService {
|
|||||||
Array<{
|
Array<{
|
||||||
containerNumber: string;
|
containerNumber: string;
|
||||||
goods: string | null;
|
goods: string | null;
|
||||||
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||||
grnNumber: string | null;
|
grnNumber: string | null;
|
||||||
truckAssignmentId: string | null;
|
truckAssignmentId: string | null;
|
||||||
truckPlate: string | null;
|
truckPlate: string | null;
|
||||||
truckArrived: boolean;
|
truckArrived: boolean;
|
||||||
truckLeft: boolean;
|
truckLeft: boolean;
|
||||||
|
loaded: boolean;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
|
handoverSigned: boolean;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const rows: Array<{
|
const rows: Array<{
|
||||||
@@ -2539,6 +2625,7 @@ export class WarehouseInventoryService {
|
|||||||
truckPlate: string | null;
|
truckPlate: string | null;
|
||||||
truckArrived: boolean;
|
truckArrived: boolean;
|
||||||
truckLeft: boolean;
|
truckLeft: boolean;
|
||||||
|
loaded: boolean;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
@@ -2552,6 +2639,7 @@ export class WarehouseInventoryService {
|
|||||||
a.plate_number AS "truckPlate",
|
a.plate_number AS "truckPlate",
|
||||||
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
||||||
(a.departed_at IS NOT NULL) AS "truckLeft",
|
(a.departed_at IS NOT NULL) AS "truckLeft",
|
||||||
|
(ctc.loaded_at IS NOT NULL) AS loaded,
|
||||||
b.reference AS "bookingReference",
|
b.reference AS "bookingReference",
|
||||||
b.contract_id AS "contractId",
|
b.contract_id AS "contractId",
|
||||||
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
||||||
@@ -2574,28 +2662,64 @@ export class WarehouseInventoryService {
|
|||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Booking-level gate: the per-truck exit paper is blocked until the handover
|
||||||
|
// is fully signed, so the UI can disable "Exit Paper" with a clear reason.
|
||||||
|
const handoverSigned = await this.handover.isFullySigned(bookingId);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
containerNumber: r.containerNumber,
|
containerNumber: r.containerNumber,
|
||||||
goods: r.goods,
|
goods: r.goods,
|
||||||
|
// A container the customer assigned to a truck is ASSIGNED (planned); it
|
||||||
|
// only becomes LOADED once the operator loads it (loaded_at) on truck
|
||||||
|
// leaving. Departed → LEFT, delivered → DELIVERED.
|
||||||
stage: r.delivered
|
stage: r.delivered
|
||||||
? 'DELIVERED'
|
? 'DELIVERED'
|
||||||
: r.truckLeft
|
: r.truckLeft
|
||||||
? 'LEFT'
|
? 'LEFT'
|
||||||
: r.truckAssignmentId
|
: r.loaded
|
||||||
? 'LOADED'
|
? 'LOADED'
|
||||||
: r.grnNumber
|
: r.truckAssignmentId
|
||||||
? 'GRN'
|
? 'ASSIGNED'
|
||||||
: r.received
|
: r.grnNumber
|
||||||
? 'RECEIVED'
|
? 'GRN'
|
||||||
: 'PENDING',
|
: r.received
|
||||||
|
? 'RECEIVED'
|
||||||
|
: 'PENDING',
|
||||||
grnNumber: r.grnNumber,
|
grnNumber: r.grnNumber,
|
||||||
truckAssignmentId: r.truckAssignmentId,
|
truckAssignmentId: r.truckAssignmentId,
|
||||||
truckPlate: r.truckPlate,
|
truckPlate: r.truckPlate,
|
||||||
truckArrived: r.truckArrived,
|
truckArrived: r.truckArrived,
|
||||||
truckLeft: r.truckLeft,
|
truckLeft: r.truckLeft,
|
||||||
|
loaded: r.loaded,
|
||||||
bookingReference: r.bookingReference,
|
bookingReference: r.bookingReference,
|
||||||
contractId: r.contractId,
|
contractId: r.contractId,
|
||||||
hasLastMile: r.hasLastMile,
|
hasLastMile: r.hasLastMile,
|
||||||
|
handoverSigned,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The booking's containers with their VGM cargo weight (tonnes), keyed by
|
||||||
|
* container number. Drives the truck-leaving exit weighing: the selected
|
||||||
|
* containers' total cargo weight must match (gross − tare).
|
||||||
|
*/
|
||||||
|
async bookingContainerWeights(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<Array<{ containerNumber: string; weightTons: number }>> {
|
||||||
|
const rows: Array<{ containerNumber: string; weightTons: string }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT bcu.container_number AS "containerNumber",
|
||||||
|
COALESCE(bcu.vgm_tons, 0) AS "weightTons"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
|
||||||
|
ORDER BY bcu.container_number`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
containerNumber: r.containerNumber,
|
||||||
|
weightTons: Number(r.weightTons) || 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2677,7 +2801,7 @@ export class WarehouseInventoryService {
|
|||||||
['Pickup Truck Plate', data.plateNumber],
|
['Pickup Truck Plate', data.plateNumber],
|
||||||
['Driver', data.driverName],
|
['Driver', data.driverName],
|
||||||
['Truck Type', data.truckType],
|
['Truck Type', data.truckType],
|
||||||
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`],
|
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} t`],
|
||||||
['Gate-Out Time', gateOut],
|
['Gate-Out Time', gateOut],
|
||||||
['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
|
['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
|
||||||
];
|
];
|
||||||
@@ -2901,6 +3025,21 @@ export class WarehouseInventoryService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
|
||||||
|
async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.warehouse_inventory
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY updated_at DESC NULLS LAST, created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!inv) {
|
||||||
|
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
||||||
|
}
|
||||||
|
return this.handoverDocument(inv.id);
|
||||||
|
}
|
||||||
|
|
||||||
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.id,
|
`SELECT inv.id,
|
||||||
@@ -3127,7 +3266,22 @@ export class WarehouseInventoryService {
|
|||||||
[item.bookingId],
|
[item.bookingId],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
|
// EDR last-mile: the handover is per delivering truck. Resolve the
|
||||||
|
// vehicle that carried this item's container so each truck gets its own
|
||||||
|
// handover (falls back to a booking-level one when unresolvable).
|
||||||
|
let truckPlate: string | null = null;
|
||||||
|
if (item.containerId) {
|
||||||
|
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||||||
|
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||||||
|
FROM freight.last_mile_container_allocations lca
|
||||||
|
JOIN freight.vehicles v ON v.id = lca.vehicle_id
|
||||||
|
WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[item.containerId],
|
||||||
|
);
|
||||||
|
truckPlate = veh?.plate ?? null;
|
||||||
|
}
|
||||||
|
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3608,8 +3762,8 @@ export class WarehouseInventoryService {
|
|||||||
['Booking Containers', data.bookingContainerSummary],
|
['Booking Containers', data.bookingContainerSummary],
|
||||||
['Cargo / Goods Description', data.cargoDescription],
|
['Cargo / Goods Description', data.cargoDescription],
|
||||||
['Quantity', data.quantity],
|
['Quantity', data.quantity],
|
||||||
['Received Weight', `${data.weight.toLocaleString()} kg`],
|
['Received Weight', `${data.weight.toLocaleString()} t`],
|
||||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
|
||||||
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
|
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
|
||||||
['Warehouse', data.warehouse],
|
['Warehouse', data.warehouse],
|
||||||
['Yard', data.yard],
|
['Yard', data.yard],
|
||||||
@@ -3731,7 +3885,7 @@ export class WarehouseInventoryService {
|
|||||||
`${(data.truckPlateNumber && data.truckWeightKg
|
`${(data.truckPlateNumber && data.truckWeightKg
|
||||||
? data.truckWeightKg
|
? data.truckWeightKg
|
||||||
: data.weight
|
: data.weight
|
||||||
).toLocaleString()} kg`,
|
).toLocaleString()} t`,
|
||||||
],
|
],
|
||||||
['Warehouse', data.warehouse],
|
['Warehouse', data.warehouse],
|
||||||
['Yard', data.yard],
|
['Yard', data.yard],
|
||||||
@@ -3894,8 +4048,8 @@ export class WarehouseInventoryService {
|
|||||||
['Booking Containers', data.bookingContainerSummary],
|
['Booking Containers', data.bookingContainerSummary],
|
||||||
['Cargo / Goods Description', data.cargoDescription],
|
['Cargo / Goods Description', data.cargoDescription],
|
||||||
['Quantity', data.quantity],
|
['Quantity', data.quantity],
|
||||||
['Inventory Weight', `${data.weight.toLocaleString()} kg`],
|
['Inventory Weight', `${data.weight.toLocaleString()} t`],
|
||||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
|
||||||
['Warehouse', data.warehouse],
|
['Warehouse', data.warehouse],
|
||||||
['Yard', data.yard],
|
['Yard', data.yard],
|
||||||
['Zone', data.zone],
|
['Zone', data.zone],
|
||||||
@@ -3968,8 +4122,8 @@ export class WarehouseInventoryService {
|
|||||||
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
|
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
|
||||||
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
|
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
|
||||||
<tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
|
<tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
|
||||||
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
|
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} t`)}</td></tr>
|
||||||
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)}</td></tr>
|
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null)}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="section-title">Handover Clause</div>
|
<div class="section-title">Handover Clause</div>
|
||||||
@@ -4303,9 +4457,9 @@ export class WarehouseInventoryService {
|
|||||||
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
|
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
|
||||||
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
||||||
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
||||||
`Tare Weight: ${tareWeight} kg`,
|
`Tare Weight: ${tareWeight} t`,
|
||||||
grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
|
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
|
||||||
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
|
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
|
||||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -4357,7 +4511,7 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
|
private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
|
||||||
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, '');
|
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*(kg|t)$/i, '');
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
@@ -4420,9 +4574,9 @@ export class WarehouseInventoryService {
|
|||||||
truck?.driverName ? `Driver: ${truck.driverName}` : null,
|
truck?.driverName ? `Driver: ${truck.driverName}` : null,
|
||||||
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
|
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
|
||||||
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||||||
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
|
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} t` : null,
|
||||||
truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
|
truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
|
||||||
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} t` : null,
|
||||||
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||||
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||||
truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
||||||
@@ -4430,8 +4584,8 @@ export class WarehouseInventoryService {
|
|||||||
truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
||||||
truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
||||||
truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
||||||
truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
|
truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} t` : null,
|
||||||
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
|
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} t` : null,
|
||||||
truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
||||||
truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
||||||
truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { OnEvent } from "@nestjs/event-emitter";
|
import { OnEvent } from "@nestjs/event-emitter";
|
||||||
import { Freight } from "@edr/types";
|
import { Freight, NotificationAudience, NotificationType } from "@edr/types";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BillingService,
|
BillingService,
|
||||||
InvoiceEventPayload,
|
InvoiceEventPayload,
|
||||||
@@ -135,6 +137,7 @@ export class WarehouseInvoiceService {
|
|||||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
private readonly feeService: WarehouseFeeService,
|
private readonly feeService: WarehouseFeeService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Generation ───────────────────────────────────────────────────────────
|
// ── Generation ───────────────────────────────────────────────────────────
|
||||||
@@ -968,6 +971,25 @@ export class WarehouseInvoiceService {
|
|||||||
message,
|
message,
|
||||||
`warehouse fee invoice ${invoice.invoiceNumber}`,
|
`warehouse fee invoice ${invoice.invoiceNumber}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// In-app deep-link to pay the fee from the booking.
|
||||||
|
if (invoice.customerId && invoice.bookingId) {
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: invoice.customerId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
title: "Warehouse fee due",
|
||||||
|
body:
|
||||||
|
`Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` +
|
||||||
|
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`,
|
||||||
|
link: `/bookings/${invoice.bookingId}`,
|
||||||
|
data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async notifyWarehouseFeePayment(
|
private async notifyWarehouseFeePayment(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FilesModule } from '../files/files.module';
|
|||||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||||
@@ -75,6 +76,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
forwardRef(() => LastMileModule),
|
forwardRef(() => LastMileModule),
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
NotificationInboxModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
ExchangeModule.forRootAsync({
|
ExchangeModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export class WarehousesService {
|
|||||||
currentWeight: 0,
|
currentWeight: 0,
|
||||||
currentContainers: 0,
|
currentContainers: 0,
|
||||||
currentVolume: 0,
|
currentVolume: 0,
|
||||||
status: 'ACTIVE',
|
status: dto.status ?? 'ACTIVE',
|
||||||
isActive: true,
|
isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.mapDbError(error);
|
this.mapDbError(error);
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'),
|
perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'),
|
||||||
perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'),
|
perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'),
|
||||||
perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'),
|
perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'),
|
||||||
|
perm('e2c00001-0001-4000-8000-000000000002', 'edr_freight_app:tracking:manage', 'Manage GPS trackers'),
|
||||||
perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'),
|
perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'),
|
||||||
perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'),
|
perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'),
|
||||||
perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'),
|
perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'),
|
||||||
@@ -477,6 +478,7 @@ export const FREIGHT_PERMS = {
|
|||||||
},
|
},
|
||||||
tracking: {
|
tracking: {
|
||||||
view: 'edr_freight_app:tracking:view',
|
view: 'edr_freight_app:tracking:view',
|
||||||
|
manage: 'edr_freight_app:tracking:manage',
|
||||||
},
|
},
|
||||||
fuel: {
|
fuel: {
|
||||||
view: 'edr_freight_app:fuel:view',
|
view: 'edr_freight_app:fuel:view',
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
VITE_API_URL=http://localhost:3001
|
VITE_API_URL=http://localhost:3001
|
||||||
VITE_BASE_API_URL=http://localhost:3001
|
VITE_BASE_API_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# Proactive token refresh cadence (minutes). Must stay well under the 60-min
|
||||||
|
# server session window. Default: 10.
|
||||||
|
VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
setCookie,
|
setCookie,
|
||||||
} from "./cookies";
|
} from "./cookies";
|
||||||
import { applyTokens } from "./http";
|
import { applyTokens } from "./http";
|
||||||
|
import {
|
||||||
|
startTokenRefreshScheduler,
|
||||||
|
stopTokenRefreshScheduler,
|
||||||
|
} from "./refreshScheduler";
|
||||||
import type { AuthTokens, AuthUser } from "./types";
|
import type { AuthTokens, AuthUser } from "./types";
|
||||||
|
|
||||||
interface LoginPayload {
|
interface LoginPayload {
|
||||||
@@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
void bootstrap();
|
void bootstrap();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Keep the server session alive while a user is logged in. Runs after
|
||||||
|
// login, MFA verification, and page-reload bootstrap alike.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
stopTokenRefreshScheduler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTokenRefreshScheduler();
|
||||||
|
return stopTokenRefreshScheduler;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const value = useMemo<AuthContextValue>(
|
const value = useMemo<AuthContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => {
|
|||||||
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
|
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-flight token refresh: concurrent callers (the 401 interceptor and
|
||||||
|
* the proactive scheduler) share one in-flight request so the refresh token
|
||||||
|
* is only rotated once. Throws if no refresh token is stored or the server
|
||||||
|
* rejects it — callers decide how to end the session.
|
||||||
|
*/
|
||||||
|
const refreshSessionTokens = async (): Promise<AuthTokens> => {
|
||||||
|
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error("missing refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPromise ??= api
|
||||||
|
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
|
||||||
|
.then((response) => response.data)
|
||||||
|
.finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = await refreshPromise;
|
||||||
|
applyTokens(tokens);
|
||||||
|
return tokens;
|
||||||
|
};
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = getCookie(AUTH_TOKEN_COOKIE);
|
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||||
|
|
||||||
@@ -65,8 +89,7 @@ api.interceptors.response.use(
|
|||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
|
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
|
||||||
if (!refreshToken) {
|
|
||||||
clearSessionCookies();
|
clearSessionCookies();
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
@@ -74,15 +97,7 @@ api.interceptors.response.use(
|
|||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
refreshPromise ??= api
|
const tokens = await refreshSessionTokens();
|
||||||
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
|
|
||||||
.then((response) => response.data)
|
|
||||||
.finally(() => {
|
|
||||||
refreshPromise = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokens = await refreshPromise;
|
|
||||||
applyTokens(tokens);
|
|
||||||
originalRequest.headers = {
|
originalRequest.headers = {
|
||||||
...originalRequest.headers,
|
...originalRequest.headers,
|
||||||
Authorization: `Bearer ${tokens.token}`,
|
Authorization: `Bearer ${tokens.token}`,
|
||||||
@@ -97,4 +112,4 @@ api.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export { api, applyTokens };
|
export { api, applyTokens, refreshSessionTokens };
|
||||||
|
|||||||
82
apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
Normal file
82
apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
|
import {
|
||||||
|
REFRESH_TOKEN_COOKIE,
|
||||||
|
clearSessionCookies,
|
||||||
|
getCookie,
|
||||||
|
} from "./cookies";
|
||||||
|
import { refreshSessionTokens } from "./http";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proactively refreshes the token pair on a fixed cadence so the server-side
|
||||||
|
* session (a sliding 1-hour window, extended only by /auth/refresh-token) is
|
||||||
|
* kept alive while the app is open. The 401 interceptor in http.ts remains
|
||||||
|
* the reactive fallback; both share the same single-flight refresh call.
|
||||||
|
*
|
||||||
|
* The interval MUST stay well under the server session window (60 min).
|
||||||
|
*/
|
||||||
|
const DEFAULT_INTERVAL_MINUTES = 10;
|
||||||
|
|
||||||
|
const getIntervalMs = () => {
|
||||||
|
const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES);
|
||||||
|
return (
|
||||||
|
(Number.isFinite(minutes) && minutes > 0
|
||||||
|
? minutes
|
||||||
|
: DEFAULT_INTERVAL_MINUTES) * 60_000
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let timerId: number | null = null;
|
||||||
|
let lastRefreshAt = 0;
|
||||||
|
|
||||||
|
const refreshNow = async () => {
|
||||||
|
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
|
||||||
|
// Logged out elsewhere; nothing to keep alive.
|
||||||
|
stopTokenRefreshScheduler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshSessionTokens();
|
||||||
|
lastRefreshAt = Date.now();
|
||||||
|
} catch (error) {
|
||||||
|
// Network hiccups are retried on the next tick; only an explicit server
|
||||||
|
// rejection means the session is dead.
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
stopTokenRefreshScheduler();
|
||||||
|
clearSessionCookies();
|
||||||
|
window.location.replace("/auth");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browsers freeze timers in background tabs — a tab waking up past its
|
||||||
|
* refresh deadline refreshes immediately instead of waiting a full interval.
|
||||||
|
*/
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
if (Date.now() - lastRefreshAt >= getIntervalMs()) {
|
||||||
|
void refreshNow();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startTokenRefreshScheduler = () => {
|
||||||
|
stopTokenRefreshScheduler();
|
||||||
|
|
||||||
|
// Token age is unknown here (fresh login vs. hours-old page reload), so
|
||||||
|
// refresh right away to extend the session window from "now".
|
||||||
|
lastRefreshAt = 0;
|
||||||
|
void refreshNow();
|
||||||
|
|
||||||
|
timerId = window.setInterval(() => void refreshNow(), getIntervalMs());
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopTokenRefreshScheduler = () => {
|
||||||
|
if (timerId !== null) {
|
||||||
|
window.clearInterval(timerId);
|
||||||
|
timerId = null;
|
||||||
|
}
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
};
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Anchor,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ClipboardCheck,
|
||||||
|
Clock,
|
||||||
|
FilePlus2,
|
||||||
|
FileX2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useFileViewer } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||||
|
import { formatDate, humanize } from "./format";
|
||||||
|
|
||||||
|
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
companyName: "Company name",
|
||||||
|
companyEmail: "Company email",
|
||||||
|
companyPhone: "Company phone",
|
||||||
|
companyLocation: "Location",
|
||||||
|
companyAddress: "Address",
|
||||||
|
tin: "TIN",
|
||||||
|
vatNumber: "VAT number",
|
||||||
|
fanNumber: "FAN number",
|
||||||
|
nationality: "Nationality",
|
||||||
|
licenceNumber: "Licence number",
|
||||||
|
contactPersonName: "Contact person",
|
||||||
|
contactPersonPosition: "Contact position",
|
||||||
|
contactPersonEmail: "Contact email",
|
||||||
|
contactPersonPhone: "Contact phone",
|
||||||
|
generalManagerName: "General manager",
|
||||||
|
generalManagerEmail: "GM email",
|
||||||
|
generalManagerPhone: "GM phone",
|
||||||
|
poaName: "PoA name",
|
||||||
|
poaPhone: "PoA phone",
|
||||||
|
poaEmail: "PoA email",
|
||||||
|
poaLocation: "PoA location",
|
||||||
|
poaAddress: "PoA address",
|
||||||
|
region: "Region",
|
||||||
|
zone: "Zone",
|
||||||
|
woreda: "Woreda",
|
||||||
|
kebele: "Kebele",
|
||||||
|
houseNo: "House no.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Best-effort current value on the live company for a proposed field key. */
|
||||||
|
function currentValue(company: Company, key: string): string {
|
||||||
|
const c = company as unknown as Record<string, unknown>;
|
||||||
|
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
||||||
|
const map: Record<string, unknown> = {
|
||||||
|
companyName: c.name,
|
||||||
|
companyEmail: c.email,
|
||||||
|
companyPhone: c.phone,
|
||||||
|
companyLocation: c.country,
|
||||||
|
companyAddress: c.address,
|
||||||
|
tin: c.tin,
|
||||||
|
vatNumber: c.vatNumber,
|
||||||
|
fanNumber: c.fanNumber,
|
||||||
|
nationality: c.nationality,
|
||||||
|
contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
|
||||||
|
contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
|
||||||
|
generalManagerName: c.generalManagerName ?? attrs.generalManagerName,
|
||||||
|
generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail,
|
||||||
|
generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone,
|
||||||
|
};
|
||||||
|
const v = key in map ? map[key] : (c[key] ?? attrs[key]);
|
||||||
|
return v === null || v === undefined || v === "" ? "—" : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffRow({
|
||||||
|
label,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}) {
|
||||||
|
const changed = from !== to;
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Group gap={8} wrap="nowrap" align="center">
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
td={changed ? "line-through" : undefined}
|
||||||
|
style={{ wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{from}
|
||||||
|
</Text>
|
||||||
|
{changed && (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="edr-muted">
|
||||||
|
→
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{to}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice review surface for a customer's staged profile edits. Shows the
|
||||||
|
* pending change request as a proposed-vs-current diff with Approve / Reject
|
||||||
|
* (with note) actions, plus a short history of past decisions.
|
||||||
|
*/
|
||||||
|
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||||
|
const query = useQuery(
|
||||||
|
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||||
|
);
|
||||||
|
const approve = useMutation(
|
||||||
|
api.customers.approveChangeRequest.mutationOptions(),
|
||||||
|
);
|
||||||
|
const reject = useMutation(
|
||||||
|
api.customers.rejectChangeRequest.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
|
const requests = query.data ?? [];
|
||||||
|
const pending = requests.find((r) => r.status === "pending");
|
||||||
|
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
|
||||||
|
|
||||||
|
if (!pending && history.length === 0) return null;
|
||||||
|
|
||||||
|
const proposedKeys = pending
|
||||||
|
? Object.keys(pending.snapshot ?? {})
|
||||||
|
: ([] as string[]);
|
||||||
|
const docCount = pending?.documentFileIds?.length ?? 0;
|
||||||
|
const licenseChanges = pending?.licenseChanges ?? [];
|
||||||
|
|
||||||
|
const confirmReject = () => {
|
||||||
|
if (!rejectId) return;
|
||||||
|
reject.mutate(
|
||||||
|
{ id: rejectId, note: note.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setRejectId(null);
|
||||||
|
setNote("");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pending && (
|
||||||
|
<Card withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ClipboardCheck size={18} className="text-edr-muted" />
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Profile changes awaiting review
|
||||||
|
</Text>
|
||||||
|
<Badge color="yellow" variant="light" radius="md">
|
||||||
|
Pending
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Submitted {formatDate(pending.submittedAt ?? pending.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{proposedKeys.length > 0 ? (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
|
{proposedKeys.map((key) => (
|
||||||
|
<DiffRow
|
||||||
|
key={key}
|
||||||
|
label={FIELD_LABELS[key] ?? humanize(key)}
|
||||||
|
from={currentValue(company, key)}
|
||||||
|
to={
|
||||||
|
pending.snapshot[key] === null ||
|
||||||
|
pending.snapshot[key] === undefined ||
|
||||||
|
pending.snapshot[key] === ""
|
||||||
|
? "—"
|
||||||
|
: String(pending.snapshot[key])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No field changes — document uploads only.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{docCount > 0 && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{docCount} document{docCount === 1 ? "" : "s"} uploaded with this
|
||||||
|
request — review them in the Documents tab.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{licenseChanges.length > 0 && (
|
||||||
|
<Stack gap={8}>
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
Business license changes
|
||||||
|
</Text>
|
||||||
|
{licenseChanges.map((c, i) => (
|
||||||
|
<Group key={`${c.fileId}-${i}`} gap={8} wrap="nowrap">
|
||||||
|
{c.op === "add" ? (
|
||||||
|
<FilePlus2 size={15} className="text-edr-muted" />
|
||||||
|
) : (
|
||||||
|
<FileX2 size={15} className="text-edr-muted" />
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
color={c.op === "add" ? "green" : "red"}
|
||||||
|
>
|
||||||
|
{c.op === "add" ? "Add" : "Remove"}
|
||||||
|
</Badge>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: c.fileName ?? "License document",
|
||||||
|
url: fileViewUrl(c.fileId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
textDecoration:
|
||||||
|
c.op === "remove" ? "line-through" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.fileName ?? "License document"}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
onClick={() => {
|
||||||
|
setRejectId(pending.id);
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={approve.isPending}
|
||||||
|
onClick={() => approve.mutate({ id: pending.id })}
|
||||||
|
>
|
||||||
|
Approve changes
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{history.length > 0 && (
|
||||||
|
<Card withBorder>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Review history
|
||||||
|
</Text>
|
||||||
|
{history.map((r: CompanyChangeRequest) => (
|
||||||
|
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
|
||||||
|
<Badge
|
||||||
|
color={r.status === "approved" ? "edr-green" : "red"}
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
tt="capitalize"
|
||||||
|
>
|
||||||
|
{r.status}
|
||||||
|
</Badge>
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Text size="sm" c="edr-text">
|
||||||
|
{formatDate(r.reviewedAt ?? r.updatedAt)}
|
||||||
|
</Text>
|
||||||
|
{r.note && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Note: {r.note}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={rejectId !== null}
|
||||||
|
onClose={() => setRejectId(null)}
|
||||||
|
title="Reject changes"
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
|
||||||
|
The customer will see this note and can amend and resubmit.
|
||||||
|
</Alert>
|
||||||
|
<Textarea
|
||||||
|
label="Reason for rejection"
|
||||||
|
placeholder="e.g. The company address doesn't match the trade license."
|
||||||
|
autosize
|
||||||
|
minRows={3}
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setRejectId(null)}
|
||||||
|
disabled={reject.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={reject.isPending}
|
||||||
|
disabled={note.trim().length === 0}
|
||||||
|
onClick={confirmReject}
|
||||||
|
>
|
||||||
|
Reject changes
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{viewer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact "N changes pending" pill for the customer list/detail header. */
|
||||||
|
export function ChangeRequestPendingBadge({ companyId }: { companyId: string }) {
|
||||||
|
const query = useQuery(
|
||||||
|
api.customers.changeRequests.queryOptions({ input: { id: companyId } }),
|
||||||
|
);
|
||||||
|
const pending = (query.data ?? []).some((r) => r.status === "pending");
|
||||||
|
if (!pending) return null;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Clock size={12} />}
|
||||||
|
>
|
||||||
|
Changes pending review
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { Badge, Button, Group, Tooltip } from "@mantine/core";
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -25,6 +35,7 @@ const badgeStyle = {
|
|||||||
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
|
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
|
||||||
active: "edr-green",
|
active: "edr-green",
|
||||||
pending: "yellow",
|
pending: "yellow",
|
||||||
|
rejected: "red",
|
||||||
suspended: "orange",
|
suspended: "orange",
|
||||||
blacklisted: "red",
|
blacklisted: "red",
|
||||||
};
|
};
|
||||||
@@ -266,7 +277,9 @@ export function InvoiceStatusBadge({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline approval action buttons for a profile row.
|
* Inline approval action buttons for a profile row.
|
||||||
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
|
* Transitions: pending → approve / reject-with-note | rejected → approve (override) |
|
||||||
|
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
|
||||||
|
* Rejecting captures a note the customer sees so they can fix and reapply.
|
||||||
*/
|
*/
|
||||||
export function ProfileApprovalActions({
|
export function ProfileApprovalActions({
|
||||||
profileId,
|
profileId,
|
||||||
@@ -278,33 +291,102 @@ export function ProfileApprovalActions({
|
|||||||
const { mutate, isPending } = useMutation(
|
const { mutate, isPending } = useMutation(
|
||||||
api.customers.setProfileStatus.mutationOptions(),
|
api.customers.setProfileStatus.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
||||||
|
|
||||||
|
const confirmReject = () => {
|
||||||
|
mutate(
|
||||||
|
{ profileId, status: "rejected", note: note.trim() },
|
||||||
|
{ onSuccess: () => setRejectOpen(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectModal = (
|
||||||
|
<Modal
|
||||||
|
opened={rejectOpen}
|
||||||
|
onClose={() => setRejectOpen(false)}
|
||||||
|
title="Reject profile"
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Tell the customer what needs fixing. They'll see this note and can
|
||||||
|
amend and resubmit the role for approval.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
label="Reason for rejection"
|
||||||
|
placeholder="e.g. The uploaded business license is expired."
|
||||||
|
autosize
|
||||||
|
minRows={3}
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setRejectOpen(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={isPending}
|
||||||
|
disabled={note.trim().length === 0}
|
||||||
|
onClick={confirmReject}
|
||||||
|
>
|
||||||
|
Reject profile
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
if (status === "pending") {
|
if (status === "pending") {
|
||||||
return (
|
return (
|
||||||
<Group gap={6} wrap="nowrap">
|
<>
|
||||||
<Button
|
{rejectModal}
|
||||||
size="xs"
|
<Group gap={6} wrap="nowrap">
|
||||||
variant="light"
|
<Button
|
||||||
color="edr-green"
|
size="xs"
|
||||||
radius="md"
|
variant="light"
|
||||||
loading={isPending}
|
color="edr-green"
|
||||||
onClick={() => act("active")}
|
radius="md"
|
||||||
>
|
loading={isPending}
|
||||||
Approve
|
onClick={() => act("active")}
|
||||||
</Button>
|
>
|
||||||
<Button
|
Approve
|
||||||
size="xs"
|
</Button>
|
||||||
variant="light"
|
<Button
|
||||||
color="red"
|
size="xs"
|
||||||
radius="md"
|
variant="light"
|
||||||
loading={isPending}
|
color="red"
|
||||||
onClick={() => act("blacklisted")}
|
radius="md"
|
||||||
>
|
onClick={() => setRejectOpen(true)}
|
||||||
Reject
|
>
|
||||||
</Button>
|
Reject
|
||||||
</Group>
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "rejected") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => act("active")}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,9 @@ export {
|
|||||||
ProfileStatusBadge,
|
ProfileStatusBadge,
|
||||||
ProfileTypeBadge,
|
ProfileTypeBadge,
|
||||||
} from "./badges";
|
} from "./badges";
|
||||||
|
export {
|
||||||
|
ChangeRequestReview,
|
||||||
|
ChangeRequestPendingBadge,
|
||||||
|
} from "./ChangeRequestReview";
|
||||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||||
export { TableCard, type TableCardProps } from "./TableCard";
|
export { TableCard, type TableCardProps } from "./TableCard";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { FileText } from 'lucide-react';
|
import { FileText } from 'lucide-react';
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
type ContainerItem,
|
type ContainerItem,
|
||||||
type ContainerItemStage,
|
type ContainerItemStage,
|
||||||
} from '@/services/warehouse.service';
|
} from '@/services/warehouse.service';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
interface ContainerItemsModalProps {
|
interface ContainerItemsModalProps {
|
||||||
@@ -36,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [
|
|||||||
{ value: 'ALL', label: 'All' },
|
{ value: 'ALL', label: 'All' },
|
||||||
{ value: 'RECEIVED', label: 'Received' },
|
{ value: 'RECEIVED', label: 'Received' },
|
||||||
{ value: 'GRN', label: "GRN'd" },
|
{ value: 'GRN', label: "GRN'd" },
|
||||||
|
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||||
{ value: 'LOADED', label: 'Loaded' },
|
{ value: 'LOADED', label: 'Loaded' },
|
||||||
{ value: 'LEFT', label: 'Left' },
|
{ value: 'LEFT', label: 'Left' },
|
||||||
{ value: 'DELIVERED', label: 'Delivered' },
|
{ value: 'DELIVERED', label: 'Delivered' },
|
||||||
@@ -45,13 +47,15 @@ const STAGE_COLOR: Record<ContainerItemStage, string> = {
|
|||||||
PENDING: 'gray',
|
PENDING: 'gray',
|
||||||
RECEIVED: 'blue',
|
RECEIVED: 'blue',
|
||||||
GRN: 'teal',
|
GRN: 'teal',
|
||||||
|
ASSIGNED: 'indigo',
|
||||||
LOADED: 'grape',
|
LOADED: 'grape',
|
||||||
LEFT: 'orange',
|
LEFT: 'orange',
|
||||||
DELIVERED: 'green',
|
DELIVERED: 'green',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Loadable = not yet on a truck (before LOADED). */
|
/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */
|
||||||
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
|
const isLoadable = (i: ContainerItem) =>
|
||||||
|
i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED';
|
||||||
|
|
||||||
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -76,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
|||||||
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
||||||
[items, tab],
|
[items, tab],
|
||||||
);
|
);
|
||||||
|
// Only arrived, not-yet-departed trucks can be loaded.
|
||||||
const truckOptions = trucks
|
const truckOptions = trucks
|
||||||
.filter((t) => !(t as { departedAt?: string }).departedAt)
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
Boolean((t as { arrivedAt?: string }).arrivedAt) &&
|
||||||
|
!(t as { departedAt?: string }).departedAt,
|
||||||
|
)
|
||||||
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
||||||
|
|
||||||
const loadMutation = useMutation({
|
const loadMutation = useMutation({
|
||||||
@@ -90,12 +99,29 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
|||||||
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const requestSign = async () => {
|
||||||
|
try {
|
||||||
|
const res = await warehouseService.requestHandoverSignature(bookingId as string);
|
||||||
|
queryClient.invalidateQueries({ queryKey: itemsKey });
|
||||||
|
if (res.alreadySigned) {
|
||||||
|
toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' });
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Handover not signed',
|
||||||
|
description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({ variant: 'destructive', title: 'Could not request signature', description: extractErrorMessage(e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openExitPaper = async (assignmentId: string, plate: string) => {
|
const openExitPaper = async (assignmentId: string, plate: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
||||||
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
|
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,16 +189,28 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
|||||||
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
||||||
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
{i.truckAssignmentId && (
|
{i.loaded && i.truckAssignmentId && (
|
||||||
<Button
|
<Tooltip
|
||||||
size="compact-xs"
|
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
||||||
variant="light"
|
disabled={i.handoverSigned}
|
||||||
color="orange"
|
withArrow
|
||||||
leftSection={<FileText size={13} />}
|
multiline
|
||||||
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
|
w={240}
|
||||||
>
|
>
|
||||||
Exit Paper
|
<Button
|
||||||
</Button>
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color={i.handoverSigned ? 'orange' : 'gray'}
|
||||||
|
leftSection={<FileText size={13} />}
|
||||||
|
onClick={() =>
|
||||||
|
i.handoverSigned
|
||||||
|
? openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')
|
||||||
|
: requestSign()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Exit Paper
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
|||||||
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
|
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
|
||||||
toast({ title: 'Warehouse updated' });
|
toast({ title: 'Warehouse updated' });
|
||||||
} else {
|
} else {
|
||||||
await createMutation.mutateAsync(payload);
|
await createMutation.mutateAsync({ ...payload, status: form.status });
|
||||||
toast({ title: 'Warehouse created' });
|
toast({ title: 'Warehouse created' });
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
@@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
|||||||
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
|
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
/>
|
/>
|
||||||
{isEdit && (
|
<Select
|
||||||
<Select
|
label="Status"
|
||||||
label="Status"
|
data={statusOptions}
|
||||||
data={statusOptions}
|
value={form.status}
|
||||||
value={form.status}
|
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
allowDeselect={false}
|
||||||
allowDeselect={false}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -169,7 +167,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
|||||||
|
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Capacity weight (kg)"
|
label="Capacity weight (t)"
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
min={0}
|
min={0}
|
||||||
value={form.capacityWeight}
|
value={form.capacityWeight}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
|
|||||||
|
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Capacity weight (kg)"
|
label="Capacity weight (t)"
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
min={0}
|
min={0}
|
||||||
value={form.capacityWeight}
|
value={form.capacityWeight}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
|
|||||||
|
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Capacity weight (kg)"
|
label="Capacity weight (t)"
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
min={0}
|
min={0}
|
||||||
value={form.capacityWeight}
|
value={form.capacityWeight}
|
||||||
|
|||||||
@@ -174,13 +174,13 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
|
|||||||
{hasWeightLoss && (
|
{hasWeightLoss && (
|
||||||
<Group grow mt="xs">
|
<Group grow mt="xs">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Expected weight (kg)"
|
label="Expected weight (t)"
|
||||||
min={0}
|
min={0}
|
||||||
value={expectedWeight}
|
value={expectedWeight}
|
||||||
onChange={(v) => setExpectedWeight(v === '' ? '' : Number(v))}
|
onChange={(v) => setExpectedWeight(v === '' ? '' : Number(v))}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Actual weight (kg)"
|
label="Actual weight (t)"
|
||||||
min={0}
|
min={0}
|
||||||
value={actualWeight}
|
value={actualWeight}
|
||||||
onChange={(v) => setActualWeight(v === '' ? '' : Number(v))}
|
onChange={(v) => setActualWeight(v === '' ? '' : Number(v))}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
|
|||||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||||
<DetailRow label="Handover reference" value={handoverReference || '-'} />
|
<DetailRow label="Handover reference" value={handoverReference || '-'} />
|
||||||
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
|
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
|
||||||
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
|
<DetailRow label="Weight" value={`${formatNumber(item.weight)} t`} />
|
||||||
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
|
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function InventoryInquiryDetailModal({ opened, onClose, result }: Invento
|
|||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
<DetailRow label="Item" value={itemLabel(result)} />
|
<DetailRow label="Item" value={itemLabel(result)} />
|
||||||
<DetailRow label="Quantity" value={formatNumber(result.quantity)} />
|
<DetailRow label="Quantity" value={formatNumber(result.quantity)} />
|
||||||
<DetailRow label="Weight" value={`${formatNumber(result.weight)} kg`} />
|
<DetailRow label="Weight" value={`${formatNumber(result.weight)} t`} />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Divider label="Location" labelPosition="left" />
|
<Divider label="Location" labelPosition="left" />
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { InventoryHistoryModal } from './InventoryHistoryModal';
|
|||||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
|
||||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractErrorMessage } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
@@ -29,12 +28,11 @@ interface InventoryWorkbenchProps {
|
|||||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
|
/** Inventory table + all lifecycle actions (advance / move / history). */
|
||||||
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
|
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
|
||||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
@@ -171,7 +169,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
const storeInventory = async (item: WarehouseInventoryItem) => {
|
const storeInventory = async (item: WarehouseInventoryItem) => {
|
||||||
setBusyId(item.id);
|
setBusyId(item.id);
|
||||||
try {
|
try {
|
||||||
const stored = await storeMutation.mutateAsync(item.id);
|
const stored = await storeMutation.mutateAsync({ id: item.id });
|
||||||
toast({
|
toast({
|
||||||
title: 'Inventory stored',
|
title: 'Inventory stored',
|
||||||
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
|
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
|
||||||
@@ -187,9 +185,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case 'store':
|
case 'store':
|
||||||
return storeInventory(item);
|
return storeInventory(item);
|
||||||
case 'reserve':
|
|
||||||
setReserveItem(item);
|
|
||||||
return;
|
|
||||||
case 'ready-for-loading':
|
case 'ready-for-loading':
|
||||||
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
|
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
|
||||||
case 'load':
|
case 'load':
|
||||||
@@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||||
<ReserveInventoryModal
|
|
||||||
opened={Boolean(reserveItem)}
|
|
||||||
onClose={() => setReserveItem(null)}
|
|
||||||
item={reserveItem}
|
|
||||||
/>
|
|
||||||
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
|
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
|
||||||
<InventoryHistoryModal
|
<InventoryHistoryModal
|
||||||
opened={Boolean(historyItem)}
|
opened={Boolean(historyItem)}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModal
|
|||||||
<WagonSelect label="Wagon" required value={wagonId} onChange={setWagonId} />
|
<WagonSelect label="Wagon" required value={wagonId} onChange={setWagonId} />
|
||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Loaded weight (kg)"
|
label="Loaded weight (t)"
|
||||||
placeholder="Defaults to item weight"
|
placeholder="Defaults to item weight"
|
||||||
min={0}
|
min={0}
|
||||||
value={loadedWeight}
|
value={loadedWeight}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const STAGE_COLOR: Record<string, string> = {
|
|||||||
LOADED: 'green',
|
LOADED: 'green',
|
||||||
};
|
};
|
||||||
|
|
||||||
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
|
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} t`);
|
||||||
|
|
||||||
interface BookingGroup {
|
interface BookingGroup {
|
||||||
bookingId: string | null;
|
bookingId: string | null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
|
ArrowRightLeft,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -27,6 +29,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
History,
|
History,
|
||||||
Info,
|
Info,
|
||||||
|
MapPin,
|
||||||
|
MoreHorizontal,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
PackageOpen,
|
PackageOpen,
|
||||||
PackageSearch,
|
PackageSearch,
|
||||||
@@ -61,7 +65,6 @@ import type {
|
|||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
import { BookingSelect } from './BookingSelect';
|
import { BookingSelect } from './BookingSelect';
|
||||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||||
import { TruckDispatchModal } from './TruckDispatchModal';
|
|
||||||
import { ContainerItemsModal } from './ContainerItemsModal';
|
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||||
import { FeePreviewModal } from './FeePreviewModal';
|
import { FeePreviewModal } from './FeePreviewModal';
|
||||||
import { InspectionReportModal } from './InspectionReportModal';
|
import { InspectionReportModal } from './InspectionReportModal';
|
||||||
@@ -69,7 +72,9 @@ import { InventoryDetailModal } from './InventoryDetailModal';
|
|||||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||||
import { InventoryWorkbench } from './InventoryWorkbench';
|
import { InventoryWorkbench } from './InventoryWorkbench';
|
||||||
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||||
|
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||||
|
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||||
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
@@ -520,14 +525,14 @@ function TruckEntranceFields({
|
|||||||
{value.weighingRequired && (
|
{value.weighingRequired && (
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Gross weight (kg)"
|
label="Gross weight (t)"
|
||||||
required
|
required
|
||||||
min={0}
|
min={0}
|
||||||
value={value.grossWeightKg}
|
value={value.grossWeightKg}
|
||||||
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Exit tare weight (kg)"
|
label="Exit tare weight (t)"
|
||||||
required
|
required
|
||||||
min={0}
|
min={0}
|
||||||
value={value.exitTareWeightKg}
|
value={value.exitTareWeightKg}
|
||||||
@@ -596,7 +601,7 @@ function TruckEntranceFields({
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Net weight (kg)"
|
label="Net weight (t)"
|
||||||
min={0}
|
min={0}
|
||||||
value={value.netWeightKg}
|
value={value.netWeightKg}
|
||||||
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
|
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
|
||||||
@@ -2182,7 +2187,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
const inspectMutation = useMutation(
|
const inspectMutation = useMutation(
|
||||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||||
);
|
);
|
||||||
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
|
||||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||||
@@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
|
|
||||||
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
|
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [storeItem, setStoreItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
|
||||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||||
const someSelected = selected.size > 0 && !allSelected;
|
const someSelected = selected.size > 0 && !allSelected;
|
||||||
@@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
<Eye size={16} />
|
<Eye size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{r.currentStatus === 'UNLOADED' && (
|
{/* Primary stage action stays visible; the rest live under the kebab. */}
|
||||||
|
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="blue"
|
color="yellow"
|
||||||
loading={busyId === r.id}
|
leftSection={<Truck size={14} />}
|
||||||
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||||
>
|
>
|
||||||
Store
|
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="orange"
|
|
||||||
loading={busyId === r.id}
|
|
||||||
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
|
|
||||||
>
|
|
||||||
Ready Pickup
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="yellow"
|
|
||||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
|
||||||
>
|
|
||||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="green"
|
|
||||||
loading={busyId === r.id}
|
|
||||||
onClick={() => setLoadTruckItem(toInventoryItem(r))}
|
|
||||||
>
|
|
||||||
Truck_dispatch
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
@@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
Exit Paper
|
Exit Paper
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
<Menu shadow="md" width={240} position="bottom-end" withinPortal>
|
||||||
<Button
|
<Menu.Target>
|
||||||
size="compact-xs"
|
<ActionIcon variant="subtle" color="gray" aria-label="More actions" loading={busyId === r.id}>
|
||||||
variant="light"
|
<MoreHorizontal size={16} />
|
||||||
color="green"
|
</ActionIcon>
|
||||||
onClick={() => setDeliverItem(toInventoryItem(r))}
|
</Menu.Target>
|
||||||
>
|
<Menu.Dropdown>
|
||||||
Deliver
|
{r.currentStatus === 'UNLOADED' && (
|
||||||
</Button>
|
<Menu.Item leftSection={<MapPin size={14} />} onClick={() => setStoreItem(toInventoryItem(r))}>
|
||||||
)}
|
Store…
|
||||||
{r.inspectionStatus === 'PASSED' && (
|
</Menu.Item>
|
||||||
<Button
|
)}
|
||||||
size="compact-xs"
|
{r.currentStatus !== 'UNLOADED' && (
|
||||||
variant="light"
|
<Menu.Item leftSection={<ArrowRightLeft size={14} />} onClick={() => setMoveItem(toInventoryItem(r))}>
|
||||||
color="teal"
|
Move…
|
||||||
leftSection={<FileText size={14} />}
|
</Menu.Item>
|
||||||
onClick={() => openHandoverDocument(r)}
|
)}
|
||||||
>
|
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||||
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
|
<Menu.Item onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
|
||||||
</Button>
|
Ready for pickup
|
||||||
)}
|
</Menu.Item>
|
||||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
)}
|
||||||
Inspect / Report
|
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||||
</Button>
|
<Menu.Item
|
||||||
<Tooltip label="Storage / fee preview" withArrow>
|
leftSection={<Truck size={14} />}
|
||||||
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
|
disabled={!r.hasAssignedTruck}
|
||||||
<PackageCheck size={16} />
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||||
</ActionIcon>
|
>
|
||||||
</Tooltip>
|
{r.hasAssignedTruck
|
||||||
<Tooltip label="History" withArrow>
|
? r.releaseOrderReference
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
|
? 'Truck leaving'
|
||||||
<History size={16} />
|
: 'Truck arrival'
|
||||||
</ActionIcon>
|
: 'Truck arrival — assign a truck first'}
|
||||||
</Tooltip>
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
|
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openReleaseDocument(r)}>
|
||||||
|
Exit paper
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
|
<Menu.Item onClick={() => setDeliverItem(toInventoryItem(r))}>Deliver</Menu.Item>
|
||||||
|
)}
|
||||||
|
{r.inspectionStatus === 'PASSED' && (
|
||||||
|
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openHandoverDocument(r)}>
|
||||||
|
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||||
|
Storage / fee preview
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item leftSection={<History size={14} />} onClick={() => setHistoryItem(toInventoryItem(r))}>
|
||||||
|
History
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
inventoryId={feeItem?.id ?? null}
|
inventoryId={feeItem?.id ?? null}
|
||||||
/>
|
/>
|
||||||
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
||||||
|
<StoreInventoryModal opened={Boolean(storeItem)} onClose={() => setStoreItem(null)} item={storeItem} />
|
||||||
|
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||||
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
||||||
<TruckDispatchModal
|
|
||||||
opened={Boolean(loadTruckItem)}
|
|
||||||
onClose={() => setLoadTruckItem(null)}
|
|
||||||
bookingId={loadTruckItem?.booking?.id ?? null}
|
|
||||||
bookingReference={loadTruckItem?.booking?.reference ?? null}
|
|
||||||
/>
|
|
||||||
<ContainerItemsModal
|
<ContainerItemsModal
|
||||||
opened={Boolean(containerItemsItem)}
|
opened={Boolean(containerItemsItem)}
|
||||||
onClose={() => setContainerItemsItem(null)}
|
onClose={() => setContainerItemsItem(null)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { Info, Scale } from 'lucide-react';
|
import { Info, Scale } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
@@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill {
|
|||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
|
|
||||||
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
|
|
||||||
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
|
|
||||||
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
|
|
||||||
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
|
|
||||||
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
|
|
||||||
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
|
|
||||||
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
|
|
||||||
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
|
|
||||||
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
|
|
||||||
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
|
|
||||||
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
|
|
||||||
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
|
|
||||||
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
|
|
||||||
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
|
|
||||||
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
|
|
||||||
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
|
|
||||||
].map(([powerPlate, trailerPlate], index) => ({
|
|
||||||
value: powerPlate,
|
|
||||||
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
|
|
||||||
trailerPlate,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const toIsoDateTime = (value: string) => {
|
const toIsoDateTime = (value: string) => {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -78,7 +55,7 @@ const lineValue = (notes: string | null | undefined, label: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
|
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
|
||||||
const value = lineValue(notes, label).replace(/\s*kg$/i, '');
|
const value = lineValue(notes, label).replace(/\s*(kg|t)$/i, '');
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? parsed : '';
|
return Number.isFinite(parsed) ? parsed : '';
|
||||||
@@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||||
enabled: opened && Boolean(bookingId),
|
enabled: opened && Boolean(bookingId),
|
||||||
});
|
});
|
||||||
|
// Per-container cargo weights — the truck's net (gross − tare) must equal the
|
||||||
|
// total cargo weight of the containers selected as loaded on it.
|
||||||
|
const { data: containerWeights = [] } = useQuery({
|
||||||
|
queryKey: ['release-container-weights', bookingId],
|
||||||
|
queryFn: () => warehouseService.getContainerWeights(bookingId as string),
|
||||||
|
enabled: opened && Boolean(bookingId),
|
||||||
|
});
|
||||||
const [reference, setReference] = useState('');
|
const [reference, setReference] = useState('');
|
||||||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||||||
@@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
truckType: t.truckType,
|
truckType: t.truckType,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
const truckSelectOptions = [
|
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||||
...assignedTruckOptions,
|
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||||||
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
|
// operator types the plate manually in the field below.
|
||||||
value: t.value,
|
const truckSelectOptions = assignedTruckOptions;
|
||||||
label: t.label,
|
|
||||||
trailerPlate: t.trailerPlate,
|
|
||||||
driverName: '',
|
|
||||||
driverPhone: '',
|
|
||||||
truckType: '',
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||||
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
||||||
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
|
||||||
|
// Which containers ride this truck, and their combined cargo weight. When the
|
||||||
|
// booking has container weights, that sum is the authoritative net; the
|
||||||
|
// operator selects the containers loaded on the truck at exit.
|
||||||
|
const hasContainerWeights = containerWeights.length > 0;
|
||||||
|
const containerWeightByNumber = new Map(
|
||||||
|
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||||||
|
);
|
||||||
|
const containerSelectData = containerWeights.map((c) => ({
|
||||||
|
value: c.containerNumber,
|
||||||
|
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||||||
|
}));
|
||||||
|
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||||
|
const selectedCargoWeight = Number(
|
||||||
|
selectedContainerNumbers
|
||||||
|
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
||||||
|
.toFixed(3),
|
||||||
|
);
|
||||||
|
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
||||||
|
|
||||||
|
const systemNetWeight = useContainerNet
|
||||||
|
? selectedCargoWeight
|
||||||
|
: item?.weight == null
|
||||||
|
? netWeight
|
||||||
|
: Number(item.weight);
|
||||||
const computedNetWeight =
|
const computedNetWeight =
|
||||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||||
const weightMismatch =
|
const weightMismatch =
|
||||||
@@ -246,6 +247,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||||
|
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isExitStep && systemNetWeight === '') {
|
if (isExitStep && systemNetWeight === '') {
|
||||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||||
return;
|
return;
|
||||||
@@ -336,23 +341,27 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
|
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Select
|
{truckSelectOptions.length > 0 && (
|
||||||
label="Registered first / last-mile truck"
|
<Select
|
||||||
placeholder="Select truck or type plate manually below"
|
label="Assigned first / last-mile truck"
|
||||||
searchable
|
placeholder="Select the assigned truck"
|
||||||
clearable
|
searchable
|
||||||
data={truckSelectOptions}
|
clearable
|
||||||
disabled={isTruckIdentityLocked}
|
// Enabled at arrival so the operator picks which assigned truck came;
|
||||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
// only locked on the exit (leaving) step once identity is captured.
|
||||||
onChange={(value) => {
|
disabled={isEntranceLocked}
|
||||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
data={truckSelectOptions}
|
||||||
setTruckPlateNumber(truck?.value ?? '');
|
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
onChange={(value) => {
|
||||||
if (truck?.driverName) setDriverName(truck.driverName);
|
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
setTruckPlateNumber(truck?.value ?? '');
|
||||||
if (truck?.truckType) setTruckType(truck.truckType);
|
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||||
}}
|
if (truck?.driverName) setDriverName(truck.driverName);
|
||||||
/>
|
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||||
|
if (truck?.truckType) setTruckType(truck.truckType);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Truck plate number"
|
label="Truck plate number"
|
||||||
@@ -376,34 +385,55 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow align="flex-start">
|
||||||
<Stack gap={6}>
|
{hasContainerWeights ? (
|
||||||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
<MultiSelect
|
||||||
{containerNumbers.map((containerNumber, index) => (
|
label="Containers on this truck"
|
||||||
<TextInput
|
description={
|
||||||
key={index}
|
isExitStep
|
||||||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
? 'Select the containers loaded on this truck — their cargo weight must match gross − tare.'
|
||||||
value={containerNumber}
|
: 'Containers this truck will carry.'
|
||||||
onChange={(e) =>
|
}
|
||||||
setContainerNumbers((numbers) =>
|
placeholder="Select containers"
|
||||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
searchable
|
||||||
)
|
data={containerSelectData}
|
||||||
}
|
value={selectedContainerNumbers}
|
||||||
readOnly={isTruckIdentityLocked}
|
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
</SimpleGrid>
|
<Stack gap={6}>
|
||||||
</Stack>
|
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||||||
|
{containerNumbers.map((containerNumber, index) => (
|
||||||
|
<TextInput
|
||||||
|
key={index}
|
||||||
|
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||||||
|
value={containerNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
setContainerNumbers((numbers) =>
|
||||||
|
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
readOnly={isTruckIdentityLocked}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
||||||
<NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
||||||
<NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
|
<NumberInput
|
||||||
|
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||||
|
min={0}
|
||||||
|
value={systemNetWeight}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
|
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api } from '@/services/api';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
|
import { extractErrorMessage } from './options';
|
||||||
|
|
||||||
|
interface StoreInventoryModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
item: WarehouseInventoryItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store an unloaded import item. The operator may pick warehouse → yard → zone
|
||||||
|
* explicitly; leaving them blank falls back to the backend auto allocation.
|
||||||
|
*/
|
||||||
|
export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||||
|
const [warehouseId, setWarehouseId] = useState('');
|
||||||
|
const [yardId, setYardId] = useState('');
|
||||||
|
const [zoneId, setZoneId] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (opened) {
|
||||||
|
setWarehouseId('');
|
||||||
|
setYardId('');
|
||||||
|
setZoneId('');
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
const warehousesQuery = useQuery(
|
||||||
|
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
||||||
|
);
|
||||||
|
const yardsQuery = useQuery(
|
||||||
|
api.warehouses.listYards.queryOptions({
|
||||||
|
input: { warehouseId },
|
||||||
|
enabled: Boolean(warehouseId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const zonesQuery = useQuery(
|
||||||
|
api.warehouses.listZones.queryOptions({
|
||||||
|
input: { yardId },
|
||||||
|
enabled: Boolean(yardId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const warehouseOptions = useMemo(
|
||||||
|
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||||
|
[warehousesQuery.data],
|
||||||
|
);
|
||||||
|
const yardOptions = useMemo(
|
||||||
|
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||||
|
[yardsQuery.data],
|
||||||
|
);
|
||||||
|
const zoneOptions = useMemo(
|
||||||
|
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||||
|
[zonesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isManual = Boolean(warehouseId || yardId || zoneId);
|
||||||
|
const manualComplete = Boolean(warehouseId && yardId && zoneId);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!item) return;
|
||||||
|
if (isManual && !manualComplete) {
|
||||||
|
toast({ variant: 'destructive', title: 'Pick warehouse, yard and zone — or clear all to auto-allocate' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await storeMutation.mutateAsync({
|
||||||
|
id: item.id,
|
||||||
|
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
|
||||||
|
});
|
||||||
|
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Store inventory" centered size="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||||
|
<Text size="sm">
|
||||||
|
Choose a warehouse, yard and zone to store this item at a specific location, or leave them
|
||||||
|
blank to let the system auto-allocate by rule / available capacity.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
<Select
|
||||||
|
label="Warehouse"
|
||||||
|
placeholder="Auto-allocate"
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
data={warehouseOptions}
|
||||||
|
value={warehouseId || null}
|
||||||
|
onChange={(v) => {
|
||||||
|
setWarehouseId(v ?? '');
|
||||||
|
setYardId('');
|
||||||
|
setZoneId('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Yard"
|
||||||
|
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
disabled={!warehouseId}
|
||||||
|
data={yardOptions}
|
||||||
|
value={yardId || null}
|
||||||
|
onChange={(v) => {
|
||||||
|
setYardId(v ?? '');
|
||||||
|
setZoneId('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Zone"
|
||||||
|
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
disabled={!yardId}
|
||||||
|
data={zoneOptions}
|
||||||
|
value={zoneId || null}
|
||||||
|
onChange={(v) => setZoneId(v ?? '')}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="sm">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} loading={storeMutation.isPending}>
|
||||||
|
{manualComplete ? 'Store here' : 'Store (auto)'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,7 +41,6 @@ const itemKind = (item: WarehouseInventoryItem) => {
|
|||||||
|
|
||||||
const actionColor: Record<InventoryAction, string> = {
|
const actionColor: Record<InventoryAction, string> = {
|
||||||
store: 'blue',
|
store: 'blue',
|
||||||
reserve: 'grape',
|
|
||||||
'ready-for-loading': 'cyan',
|
'ready-for-loading': 'cyan',
|
||||||
load: 'teal',
|
load: 'teal',
|
||||||
dispatch: 'edr-green',
|
dispatch: 'edr-green',
|
||||||
|
|||||||
@@ -57,3 +57,24 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
|
|||||||
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
||||||
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
|
||||||
|
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
|
||||||
|
* `.message`. Decode the Blob to text, parse it, then fall back to the sync path.
|
||||||
|
*/
|
||||||
|
export const extractDownloadErrorMessage = async (error: unknown, fallback = 'Something went wrong') => {
|
||||||
|
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||||
|
if (responseData instanceof Blob) {
|
||||||
|
try {
|
||||||
|
const text = await responseData.text();
|
||||||
|
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||||
|
const raw = parsed?.message ?? parsed?.error;
|
||||||
|
if (Array.isArray(raw)) return raw.join(', ');
|
||||||
|
if (raw) return String(raw);
|
||||||
|
} catch {
|
||||||
|
/* not JSON — fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extractErrorMessage(error, fallback);
|
||||||
|
};
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
|
|||||||
documents: (id: string) =>
|
documents: (id: string) =>
|
||||||
["customers", "detail", id, "documents"] as const,
|
["customers", "detail", id, "documents"] as const,
|
||||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||||
|
changeRequests: (id: string) =>
|
||||||
|
["customers", "detail", id, "change-requests"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
INVOICES: {
|
INVOICES: {
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ export const URL_CONSTANTS = {
|
|||||||
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
|
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
|
||||||
PROFILE_STATUS: (profileId: string) =>
|
PROFILE_STATUS: (profileId: string) =>
|
||||||
`/companies/company-profiles/${profileId}/status`,
|
`/companies/company-profiles/${profileId}/status`,
|
||||||
|
CHANGE_REQUESTS: (companyId: string) =>
|
||||||
|
`/companies/${companyId}/change-requests`,
|
||||||
|
CHANGE_REQUEST_APPROVE: (id: string) =>
|
||||||
|
`/companies/change-requests/${id}/approve`,
|
||||||
|
CHANGE_REQUEST_REJECT: (id: string) =>
|
||||||
|
`/companies/change-requests/${id}/reject`,
|
||||||
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||||
`/bookings/by-company/${id}/customer-view`,
|
`/bookings/by-company/${id}/customer-view`,
|
||||||
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export const FREIGHT_PERMS = {
|
|||||||
},
|
},
|
||||||
tracking: {
|
tracking: {
|
||||||
view: "edr_freight_app:tracking:view",
|
view: "edr_freight_app:tracking:view",
|
||||||
|
manage: "edr_freight_app:tracking:manage",
|
||||||
},
|
},
|
||||||
fuel: {
|
fuel: {
|
||||||
view: "edr_freight_app:fuel:view",
|
view: "edr_freight_app:fuel:view",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Anchor,
|
Anchor,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -32,6 +33,8 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BookingStatusBadge,
|
BookingStatusBadge,
|
||||||
|
ChangeRequestPendingBadge,
|
||||||
|
ChangeRequestReview,
|
||||||
CompanyStatusBadge,
|
CompanyStatusBadge,
|
||||||
CompanyTypeBadge,
|
CompanyTypeBadge,
|
||||||
InvoiceStatusBadge,
|
InvoiceStatusBadge,
|
||||||
@@ -161,25 +164,85 @@ export default function CustomerDetailPage() {
|
|||||||
{
|
{
|
||||||
id: "type",
|
id: "type",
|
||||||
header: "Role",
|
header: "Role",
|
||||||
cell: ({ row }) => <ProfileTypeBadge type={row.original.type} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "reference",
|
|
||||||
header: "Reference",
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" fw={600} c="edr-text">
|
<div className="space-y-2">
|
||||||
{row.original.reference}
|
<ProfileTypeBadge type={row.original.type} />
|
||||||
</Text>
|
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{row.original.reference}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "businessLicense",
|
id: "licenseFiles",
|
||||||
header: "Business license",
|
header: "License documents",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<Text size="sm" c="dimmed">
|
const files = row.original.licenseFiles ?? [];
|
||||||
{row.original.businessLicense || "—"}
|
if (files.length === 0) {
|
||||||
</Text>
|
return (
|
||||||
),
|
<Text size="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Stack gap={4}>
|
||||||
|
{files.map((f) => (
|
||||||
|
<Group key={f.id} gap={6} wrap="nowrap">
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={`View ${f.name}`}
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: f.name,
|
||||||
|
url: fileViewUrl(f.id),
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="xs"
|
||||||
|
lineClamp={1}
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: f.name,
|
||||||
|
url: fileViewUrl(f.id),
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
maxWidth: 170,
|
||||||
|
textAlign: "left",
|
||||||
|
textDecoration:
|
||||||
|
f.status === "pending_remove"
|
||||||
|
? "line-through"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Anchor>
|
||||||
|
{f.status === "pending_add" && (
|
||||||
|
<Badge size="xs" color="yellow" variant="light">
|
||||||
|
Pending
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{f.status === "pending_remove" && (
|
||||||
|
<Badge size="xs" color="red" variant="light">
|
||||||
|
Removing
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
@@ -207,7 +270,7 @@ export default function CustomerDetailPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[view],
|
||||||
);
|
);
|
||||||
|
|
||||||
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
||||||
@@ -501,13 +564,13 @@ export default function CustomerDetailPage() {
|
|||||||
]}
|
]}
|
||||||
backTo="/dashboard/customers"
|
backTo="/dashboard/customers"
|
||||||
title={company.name}
|
title={company.name}
|
||||||
subtitle={`TIN ${company.tin}${
|
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||||
company.country ? ` · ${company.country}` : ""
|
}`}
|
||||||
}`}
|
|
||||||
meta={
|
meta={
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<CompanyTypeBadge type={company.type} />
|
<CompanyTypeBadge type={company.type} />
|
||||||
<CompanyStatusBadge status={company.status} />
|
<CompanyStatusBadge status={company.status} />
|
||||||
|
<ChangeRequestPendingBadge companyId={company.id} />
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -534,6 +597,8 @@ export default function CustomerDetailPage() {
|
|||||||
{/* OVERVIEW */}
|
{/* OVERVIEW */}
|
||||||
<Tabs.Panel value="overview" pt="lg">
|
<Tabs.Panel value="overview" pt="lg">
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
<ChangeRequestReview company={company} />
|
||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
@@ -614,7 +679,7 @@ export default function CustomerDetailPage() {
|
|||||||
<ProfileChips profiles={company.companyProfiles} />
|
<ProfileChips profiles={company.companyProfiles} />
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
<Box miw={860}>
|
<Box miw={1040}>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={profileColumns}
|
columns={profileColumns}
|
||||||
data={company.companyProfiles}
|
data={company.companyProfiles}
|
||||||
@@ -641,9 +706,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
bookingsQuery.isError
|
bookingsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load bookings.",
|
message: "Failed to load bookings.",
|
||||||
onRetry: () => void bookingsQuery.refetch(),
|
onRetry: () => void bookingsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -663,9 +728,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
documentsQuery.isError
|
documentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load documents.",
|
message: "Failed to load documents.",
|
||||||
onRetry: () => void documentsQuery.refetch(),
|
onRetry: () => void documentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -679,12 +744,12 @@ export default function CustomerDetailPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{licenseProfiles.map((p) => (
|
{licenseProfiles.map((p) => (
|
||||||
<Stack key={p.id} gap={4}>
|
<Stack key={p.id} gap={6}>
|
||||||
<Text size="sm" fw={600} c="edr-text">
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
{humanize(p.type)} · {p.reference}
|
{humanize(p.type)} · {p.reference}
|
||||||
</Text>
|
</Text>
|
||||||
{(p.licenseFiles ?? []).map((f) => (
|
{(p.licenseFiles ?? []).map((f) => (
|
||||||
<Group key={f.url} gap={6} wrap="nowrap">
|
<Group key={f.id} gap={8} wrap="nowrap">
|
||||||
<Paperclip size={13} className="text-edr-muted" />
|
<Paperclip size={13} className="text-edr-muted" />
|
||||||
<Anchor
|
<Anchor
|
||||||
component="button"
|
component="button"
|
||||||
@@ -692,16 +757,37 @@ export default function CustomerDetailPage() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
view({
|
||||||
name: f.name,
|
name: f.name,
|
||||||
url: f.url,
|
url: fileViewUrl(f.id),
|
||||||
mimeType: f.mimeType,
|
mimeType: f.mimeType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
size="xs"
|
size="xs"
|
||||||
|
style={{
|
||||||
|
textDecoration:
|
||||||
|
f.status === "pending_remove"
|
||||||
|
? "line-through"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{f.name}
|
{f.name}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
|
{f.status === "pending_add" && (
|
||||||
|
<Badge size="xs" color="yellow" variant="light">
|
||||||
|
Pending approval
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{f.status === "pending_remove" && (
|
||||||
|
<Badge size="xs" color="red" variant="light">
|
||||||
|
Removal pending
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
{(p.licenseFiles ?? []).length === 0 && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
No license documents.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -723,9 +809,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
paymentsQuery.isError
|
paymentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load payments.",
|
message: "Failed to load payments.",
|
||||||
onRetry: () => void paymentsQuery.refetch(),
|
onRetry: () => void paymentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -746,9 +832,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
invoicesQuery.isError
|
invoicesQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load invoices.",
|
message: "Failed to load invoices.",
|
||||||
onRetry: () => void invoicesQuery.refetch(),
|
onRetry: () => void invoicesQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
pagination={{
|
pagination={{
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
|
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
|
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
@@ -151,6 +153,8 @@ function RouteTrail({ path }: { path: LatLng[] }) {
|
|||||||
export function TrackingPage() {
|
export function TrackingPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||||
const [mapsReady, setMapsReady] = useState(false);
|
const [mapsReady, setMapsReady] = useState(false);
|
||||||
@@ -288,9 +292,11 @@ export function TrackingPage() {
|
|||||||
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
|
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
|
||||||
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
|
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
{canManage && (
|
||||||
Register tracker
|
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
||||||
</Button>
|
Register tracker
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
@@ -358,9 +364,11 @@ export function TrackingPage() {
|
|||||||
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
|
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
|
||||||
{selected.online ? "Live" : "Offline"}
|
{selected.online ? "Live" : "Offline"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
|
{canManage && (
|
||||||
<Trash2 size={16} />
|
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
|
||||||
</ActionIcon>
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -393,6 +401,7 @@ export function TrackingPage() {
|
|||||||
data={vehicleOptions}
|
data={vehicleOptions}
|
||||||
value={selected.vehicleId ?? null}
|
value={selected.vehicleId ?? null}
|
||||||
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
|
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
|
||||||
|
disabled={!canManage}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
@@ -421,14 +430,16 @@ export function TrackingPage() {
|
|||||||
<Table.Td align="right">
|
<Table.Td align="right">
|
||||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||||
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
|
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
|
||||||
<ActionIcon
|
{canManage && (
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
size="sm"
|
variant="subtle"
|
||||||
aria-label="Edit tracker"
|
size="sm"
|
||||||
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
aria-label="Edit tracker"
|
||||||
>
|
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
||||||
<Pencil size={15} />
|
>
|
||||||
</ActionIcon>
|
<Pencil size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
|
|||||||
handoverDocumentReference: row.handoverDocumentReference,
|
handoverDocumentReference: row.handoverDocumentReference,
|
||||||
handoverDocumentDate: row.handoverDocumentDate,
|
handoverDocumentDate: row.handoverDocumentDate,
|
||||||
deliveredAt: row.deliveredAt,
|
deliveredAt: row.deliveredAt,
|
||||||
|
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
|
||||||
|
// details captured at arrival (plate, driver, tare, gate-in).
|
||||||
|
notes: row.notes,
|
||||||
booking: row.bookingId
|
booking: row.bookingId
|
||||||
? {
|
? {
|
||||||
id: row.bookingId,
|
id: row.bookingId,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const columns: ColumnDef<Loading>[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'weight',
|
id: 'weight',
|
||||||
header: 'Loaded Weight (kg)',
|
header: 'Loaded Weight (t)',
|
||||||
cell: ({ row }) => formatNumber(row.original.loadedWeight),
|
cell: ({ row }) => formatNumber(row.original.loadedWeight),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
|
|||||||
},
|
},
|
||||||
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
|
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
|
||||||
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
|
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
|
||||||
{ id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) },
|
{ id: 'weight', header: 'Weight (t)', cell: ({ row }) => formatNumber(row.original.weight) },
|
||||||
{
|
{
|
||||||
id: 'payment',
|
id: 'payment',
|
||||||
header: 'Payment',
|
header: 'Payment',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
useDeleteAllocationRule,
|
useDeleteAllocationRule,
|
||||||
useDeleteFeeRule,
|
useDeleteFeeRule,
|
||||||
useFeeRules,
|
useFeeRules,
|
||||||
|
useUpdateAllocationRule,
|
||||||
|
useUpdateFeeRule,
|
||||||
} from '@/hooks/useWarehouses';
|
} from '@/hooks/useWarehouses';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +40,8 @@ import {
|
|||||||
FEE_RULE_TYPES,
|
FEE_RULE_TYPES,
|
||||||
FEE_RULE_TYPE_LABELS,
|
FEE_RULE_TYPE_LABELS,
|
||||||
VEHICLE_TYPES,
|
VEHICLE_TYPES,
|
||||||
|
type AllocationRule,
|
||||||
|
type FeeRule,
|
||||||
type FeeRuleBasis,
|
type FeeRuleBasis,
|
||||||
type FeeRuleType,
|
type FeeRuleType,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
@@ -121,8 +125,10 @@ function AllocationRules() {
|
|||||||
const { data, isLoading } = useAllocationRules();
|
const { data, isLoading } = useAllocationRules();
|
||||||
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
|
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
|
||||||
const create = useCreateAllocationRule();
|
const create = useCreateAllocationRule();
|
||||||
|
const update = useUpdateAllocationRule();
|
||||||
const remove = useDeleteAllocationRule();
|
const remove = useDeleteAllocationRule();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
priority: 100,
|
priority: 100,
|
||||||
@@ -142,7 +148,8 @@ function AllocationRules() {
|
|||||||
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const resetForm = () =>
|
const resetForm = () => {
|
||||||
|
setEditingId(null);
|
||||||
setForm({
|
setForm({
|
||||||
name: '',
|
name: '',
|
||||||
priority: 100,
|
priority: 100,
|
||||||
@@ -153,6 +160,22 @@ function AllocationRules() {
|
|||||||
targetYardCode: '',
|
targetYardCode: '',
|
||||||
storageType: '',
|
storageType: '',
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (rule: AllocationRule) => {
|
||||||
|
setForm({
|
||||||
|
name: rule.name,
|
||||||
|
priority: rule.priority ?? 100,
|
||||||
|
freightType: rule.freightType ?? '',
|
||||||
|
tradeDirection: rule.tradeDirection ?? '',
|
||||||
|
cargoTypeCode: rule.cargoTypeCode ?? '',
|
||||||
|
containerStatus: rule.containerStatus ?? '',
|
||||||
|
targetYardCode: rule.targetYardCode ?? '',
|
||||||
|
storageType: rule.storageType ?? '',
|
||||||
|
});
|
||||||
|
setEditingId(rule.id);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
||||||
@@ -160,7 +183,7 @@ function AllocationRules() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await create.mutateAsync({
|
const payload = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
priority: form.priority,
|
priority: form.priority,
|
||||||
freightType: clean(form.freightType) ?? null,
|
freightType: clean(form.freightType) ?? null,
|
||||||
@@ -170,10 +193,20 @@ function AllocationRules() {
|
|||||||
targetYardCode: form.targetYardCode.trim(),
|
targetYardCode: form.targetYardCode.trim(),
|
||||||
storageType: clean(form.storageType) ?? null,
|
storageType: clean(form.storageType) ?? null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
} as never);
|
};
|
||||||
toast({ title: 'Allocation rule created' });
|
try {
|
||||||
setOpen(false);
|
if (editingId) {
|
||||||
resetForm();
|
await update.mutateAsync({ id: editingId, payload: payload as never });
|
||||||
|
toast({ title: 'Allocation rule updated' });
|
||||||
|
} else {
|
||||||
|
await create.mutateAsync(payload as never);
|
||||||
|
toast({ title: 'Allocation rule created' });
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
resetForm();
|
||||||
|
} catch (error) {
|
||||||
|
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -182,7 +215,7 @@ function AllocationRules() {
|
|||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{rules.length} rule(s) matched by ascending priority
|
{rules.length} rule(s) matched by ascending priority
|
||||||
</Text>
|
</Text>
|
||||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||||
New allocation rule
|
New allocation rule
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -229,14 +262,19 @@ function AllocationRules() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
<ActionIcon
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
variant="subtle"
|
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||||
color="red"
|
<Pencil size={16} />
|
||||||
onClick={() => remove.mutate(rule.id)}
|
</ActionIcon>
|
||||||
title="Delete"
|
<ActionIcon
|
||||||
>
|
variant="subtle"
|
||||||
<Trash2 size={16} />
|
color="red"
|
||||||
</ActionIcon>
|
onClick={() => remove.mutate(rule.id)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
@@ -245,7 +283,7 @@ function AllocationRules() {
|
|||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Card withBorder radius="md" padding="sm" bg="gray.0">
|
<Card withBorder radius="md" padding="sm" bg="gray.0">
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
@@ -340,11 +378,11 @@ function AllocationRules() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="flex-end" mt="sm">
|
<Group justify="flex-end" mt="sm">
|
||||||
<Button variant="default" onClick={() => setOpen(false)}>
|
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button loading={create.isPending} onClick={submit}>
|
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||||
Create
|
{editingId ? 'Save changes' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -363,8 +401,10 @@ function FeeRules() {
|
|||||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||||
);
|
);
|
||||||
const create = useCreateFeeRule();
|
const create = useCreateFeeRule();
|
||||||
|
const update = useUpdateFeeRule();
|
||||||
const remove = useDeleteFeeRule();
|
const remove = useDeleteFeeRule();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||||
@@ -394,7 +434,8 @@ function FeeRules() {
|
|||||||
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
|
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
|
||||||
const isImportOnly = isDoubleHandling || isTruckDetention;
|
const isImportOnly = isDoubleHandling || isTruckDetention;
|
||||||
|
|
||||||
const resetForm = () =>
|
const resetForm = () => {
|
||||||
|
setEditingId(null);
|
||||||
setForm({
|
setForm({
|
||||||
name: '',
|
name: '',
|
||||||
ruleType: 'DEMURRAGE_FEE',
|
ruleType: 'DEMURRAGE_FEE',
|
||||||
@@ -410,6 +451,27 @@ function FeeRules() {
|
|||||||
tiers: [],
|
tiers: [],
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (rule: FeeRule) => {
|
||||||
|
setForm({
|
||||||
|
name: rule.name,
|
||||||
|
ruleType: rule.ruleType,
|
||||||
|
basis: (rule.basis as FeeRuleBasis) ?? 'PER_CONTAINER',
|
||||||
|
freightType: rule.freightType ?? '',
|
||||||
|
tradeDirection: rule.tradeDirection ?? '',
|
||||||
|
cargoTypeCode: rule.cargoTypeCode ?? '',
|
||||||
|
containerType: rule.containerType ?? '',
|
||||||
|
vehicleType: rule.vehicleType ?? '',
|
||||||
|
freeDays: rule.freeDays ?? 3,
|
||||||
|
freeHours: rule.freeHours ?? 3,
|
||||||
|
ratePerDay: rule.ratePerDay ?? 0,
|
||||||
|
tiers: (rule.tiers ?? []).map((t) => ({ fromDay: t.fromDay, toDay: t.toDay, ratePerDay: t.ratePerDay })),
|
||||||
|
currency: rule.currency ?? 'USD',
|
||||||
|
});
|
||||||
|
setEditingId(rule.id);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const addTier = () =>
|
const addTier = () =>
|
||||||
setForm((f) => {
|
setForm((f) => {
|
||||||
@@ -479,12 +541,17 @@ function FeeRules() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await create.mutateAsync(payload as never);
|
if (editingId) {
|
||||||
toast({ title: 'Fee rule created' });
|
await update.mutateAsync({ id: editingId, payload: payload as never });
|
||||||
|
toast({ title: 'Fee rule updated' });
|
||||||
|
} else {
|
||||||
|
await create.mutateAsync(payload as never);
|
||||||
|
toast({ title: 'Fee rule created' });
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (tiers.length && isUnknownTiersError(error)) {
|
if (!editingId && tiers.length && isUnknownTiersError(error)) {
|
||||||
const legacyPayload: Omit<typeof payload, 'tiers'> = {
|
const legacyPayload: Omit<typeof payload, 'tiers'> = {
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
ruleType: payload.ruleType,
|
ruleType: payload.ruleType,
|
||||||
@@ -505,7 +572,7 @@ function FeeRules() {
|
|||||||
resetForm();
|
resetForm();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
|
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -515,7 +582,7 @@ function FeeRules() {
|
|||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{rules.length} rule(s) - most specific match applies
|
{rules.length} rule(s) - most specific match applies
|
||||||
</Text>
|
</Text>
|
||||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||||
New fee rule
|
New fee rule
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -577,14 +644,19 @@ function FeeRules() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
<ActionIcon
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
variant="subtle"
|
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||||
color="red"
|
<Pencil size={16} />
|
||||||
onClick={() => remove.mutate(rule.id)}
|
</ActionIcon>
|
||||||
title="Delete"
|
<ActionIcon
|
||||||
>
|
variant="subtle"
|
||||||
<Trash2 size={16} />
|
color="red"
|
||||||
</ActionIcon>
|
onClick={() => remove.mutate(rule.id)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
@@ -593,7 +665,7 @@ function FeeRules() {
|
|||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -775,11 +847,11 @@ function FeeRules() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
<Group justify="flex-end" mt="sm">
|
<Group justify="flex-end" mt="sm">
|
||||||
<Button variant="default" onClick={() => setOpen(false)}>
|
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button loading={create.isPending} onClick={submit}>
|
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||||
Create
|
{editingId ? 'Save changes' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
|||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import type {
|
import type {
|
||||||
Company,
|
Company,
|
||||||
|
CompanyChangeRequest,
|
||||||
CompanyListFilter,
|
CompanyListFilter,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
CompanyStats,
|
CompanyStats,
|
||||||
@@ -99,6 +100,7 @@ import type {
|
|||||||
LoadInventoryPayload,
|
LoadInventoryPayload,
|
||||||
LoadPassedExportResult,
|
LoadPassedExportResult,
|
||||||
MoveInventoryPayload,
|
MoveInventoryPayload,
|
||||||
|
StoreInventoryPayload,
|
||||||
PayInvoicePayload,
|
PayInvoicePayload,
|
||||||
ReadyToLoadRow,
|
ReadyToLoadRow,
|
||||||
ReceiveInventoryPayload,
|
ReceiveInventoryPayload,
|
||||||
@@ -1051,10 +1053,13 @@ export const api = {
|
|||||||
() => [["warehouse-inventory"], ["warehouses"]],
|
() => [["warehouse-inventory"], ["warehouses"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
store: endpoint<string, WarehouseInventoryItem>(
|
store: endpoint<
|
||||||
|
{ id: string; payload?: StoreInventoryPayload },
|
||||||
|
WarehouseInventoryItem
|
||||||
|
>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"store",
|
"store",
|
||||||
(id) => warehouseService.store(id).then((r) => r.data),
|
({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => INVENTORY_INVALIDATIONS,
|
() => INVENTORY_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
@@ -2261,13 +2266,13 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
setProfileStatus: endpoint<
|
setProfileStatus: endpoint<
|
||||||
{ profileId: string; status: ProfileStatus },
|
{ profileId: string; status: ProfileStatus; note?: string },
|
||||||
CompanyProfile
|
CompanyProfile
|
||||||
>(
|
>(
|
||||||
"customers",
|
"customers",
|
||||||
"setProfileStatus",
|
"setProfileStatus",
|
||||||
({ profileId, status }) =>
|
({ profileId, status, note }) =>
|
||||||
customersService.setProfileStatus(profileId, status),
|
customersService.setProfileStatus(profileId, status, note),
|
||||||
undefined,
|
undefined,
|
||||||
(_input, data) => [
|
(_input, data) => [
|
||||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||||
@@ -2275,6 +2280,37 @@ export const api = {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
changeRequests: endpoint<{ id: string }, CompanyChangeRequest[]>(
|
||||||
|
"customers",
|
||||||
|
"changeRequests",
|
||||||
|
({ id }) => customersService.changeRequests(id),
|
||||||
|
({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
|
||||||
|
),
|
||||||
|
|
||||||
|
approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
|
||||||
|
"customers",
|
||||||
|
"approveChangeRequest",
|
||||||
|
({ id }) => customersService.approveChangeRequest(id),
|
||||||
|
undefined,
|
||||||
|
(_input, data) => [
|
||||||
|
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
|
||||||
|
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||||
|
QUERY_KEYS.CUSTOMERS.ROOT,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
rejectChangeRequest: endpoint<{ id: string; note: string }, CompanyChangeRequest>(
|
||||||
|
"customers",
|
||||||
|
"rejectChangeRequest",
|
||||||
|
({ id, note }) => customersService.rejectChangeRequest(id, note),
|
||||||
|
undefined,
|
||||||
|
(_input, data) => [
|
||||||
|
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
|
||||||
|
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||||
|
QUERY_KEYS.CUSTOMERS.ROOT,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
|
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
|
||||||
"customers",
|
"customers",
|
||||||
"setCompanyStatus",
|
"setCompanyStatus",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
|
|||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import type {
|
import type {
|
||||||
Company,
|
Company,
|
||||||
|
CompanyChangeRequest,
|
||||||
CompanyListFilter,
|
CompanyListFilter,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
CompanyStats,
|
CompanyStats,
|
||||||
@@ -80,11 +81,15 @@ export const customersService = {
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
setProfileStatus(profileId: string, status: ProfileStatus): Promise<CompanyProfile> {
|
setProfileStatus(
|
||||||
|
profileId: string,
|
||||||
|
status: ProfileStatus,
|
||||||
|
note?: string,
|
||||||
|
): Promise<CompanyProfile> {
|
||||||
return apiClient
|
return apiClient
|
||||||
.patch<CompanyProfile>(
|
.patch<CompanyProfile>(
|
||||||
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
|
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
|
||||||
{ status },
|
{ status, note },
|
||||||
)
|
)
|
||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
@@ -95,4 +100,32 @@ export const customersService = {
|
|||||||
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
|
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
|
||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** List a company's profile-edit change requests (newest first). */
|
||||||
|
changeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
|
||||||
|
return apiClient
|
||||||
|
.get<CompanyChangeRequest[]>(
|
||||||
|
URL_CONSTANTS.COMPANIES.CHANGE_REQUESTS(companyId),
|
||||||
|
)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Approve a pending change request — applies the proposed changes. */
|
||||||
|
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
|
||||||
|
return apiClient
|
||||||
|
.post<CompanyChangeRequest>(
|
||||||
|
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_APPROVE(id),
|
||||||
|
)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reject a pending change request with a note. */
|
||||||
|
rejectChangeRequest(id: string, note: string): Promise<CompanyChangeRequest> {
|
||||||
|
return apiClient
|
||||||
|
.post<CompanyChangeRequest>(
|
||||||
|
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REJECT(id),
|
||||||
|
{ note },
|
||||||
|
)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
LoadableWagon,
|
LoadableWagon,
|
||||||
LoadInventoryPayload,
|
LoadInventoryPayload,
|
||||||
MoveInventoryPayload,
|
MoveInventoryPayload,
|
||||||
|
StoreInventoryPayload,
|
||||||
ReceiveInventoryPayload,
|
ReceiveInventoryPayload,
|
||||||
ReleaseOrderPayload,
|
ReleaseOrderPayload,
|
||||||
DeliverInventoryPayload,
|
DeliverInventoryPayload,
|
||||||
@@ -63,7 +64,7 @@ import type {
|
|||||||
WarehouseZone,
|
WarehouseZone,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
|
|
||||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||||
|
|
||||||
export interface ContainerItem {
|
export interface ContainerItem {
|
||||||
containerNumber: string;
|
containerNumber: string;
|
||||||
@@ -74,9 +75,12 @@ export interface ContainerItem {
|
|||||||
truckPlate: string | null;
|
truckPlate: string | null;
|
||||||
truckArrived: boolean;
|
truckArrived: boolean;
|
||||||
truckLeft: boolean;
|
truckLeft: boolean;
|
||||||
|
/** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */
|
||||||
|
loaded: boolean;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
|
handoverSigned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
|
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
|
||||||
@@ -135,6 +139,26 @@ export const warehouseService = {
|
|||||||
return data?.data ?? data ?? [];
|
return data?.data ?? data ?? [];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Ask the customer to sign the booking's handover (creates one if none, then notifies). */
|
||||||
|
requestHandoverSignature: async (
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> => {
|
||||||
|
const { data } = await apiClient.post(
|
||||||
|
`/warehouse-inventory/bookings/${bookingId}/request-handover-signature`,
|
||||||
|
);
|
||||||
|
return data?.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
|
||||||
|
getContainerWeights: async (
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<Array<{ containerNumber: string; weightTons: number }>> => {
|
||||||
|
const { data } = await apiClient.get(
|
||||||
|
`/warehouse-inventory/bookings/${bookingId}/container-weights`,
|
||||||
|
);
|
||||||
|
return data?.data ?? data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
/** Booking container numbers not yet loaded onto any truck. */
|
/** Booking container numbers not yet loaded onto any truck. */
|
||||||
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
|
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
|
||||||
const { data } = await apiClient.get(
|
const { data } = await apiClient.get(
|
||||||
@@ -235,8 +259,8 @@ export const warehouseService = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
|
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
|
||||||
store: (id: string) =>
|
store: (id: string, payload?: StoreInventoryPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id), payload),
|
||||||
reserve: (payload: ReserveInventoryPayload) =>
|
reserve: (payload: ReserveInventoryPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
|
||||||
markReadyForLoading: (id: string) =>
|
markReadyForLoading: (id: string) =>
|
||||||
|
|||||||
@@ -30,14 +30,24 @@ export type ProfileType =
|
|||||||
| "transporter";
|
| "transporter";
|
||||||
|
|
||||||
/** Mirrors backend `ProfileStatus`. */
|
/** Mirrors backend `ProfileStatus`. */
|
||||||
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
|
export type ProfileStatus =
|
||||||
|
| "active"
|
||||||
|
| "pending"
|
||||||
|
| "rejected"
|
||||||
|
| "suspended"
|
||||||
|
| "blacklisted";
|
||||||
|
|
||||||
/** A business-license document uploaded for a company profile. */
|
/** Review state of a business-license file (mirrors API ProfileLicenseFileView). */
|
||||||
|
export type LicenseFileStatus = "live" | "pending_add" | "pending_remove";
|
||||||
|
|
||||||
|
/** A business-license document uploaded for a company profile (FileRecord-backed). */
|
||||||
export interface LicenseFile {
|
export interface LicenseFile {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
|
||||||
size: number;
|
size: number;
|
||||||
mimeType?: string;
|
mimeType: string;
|
||||||
|
/** `live` = approved; `pending_add`/`pending_remove` = awaiting review. */
|
||||||
|
status: LicenseFileStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single role a company is registered for, with its reference code. */
|
/** A single role a company is registered for, with its reference code. */
|
||||||
@@ -52,6 +62,39 @@ export interface CompanyProfile {
|
|||||||
/** Business-license documents uploaded for this profile. */
|
/** Business-license documents uploaded for this profile. */
|
||||||
licenseFiles?: LicenseFile[];
|
licenseFiles?: LicenseFile[];
|
||||||
attributes?: Record<string, unknown> | null;
|
attributes?: Record<string, unknown> | null;
|
||||||
|
/** Reviewer note when the role is rejected. */
|
||||||
|
reviewNote?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lifecycle of a staged customer profile-edit review. */
|
||||||
|
export type ChangeRequestStatus = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
/** A staged business-license add/remove on one profile, awaiting review. */
|
||||||
|
export interface LicenseChangeIntent {
|
||||||
|
profileId: string;
|
||||||
|
op: "add" | "remove";
|
||||||
|
fileId: string;
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A staged profile-edit change request. The customer's settings edits land here
|
||||||
|
* (pending) until a reviewer approves (applies them) or rejects (with a note).
|
||||||
|
*/
|
||||||
|
export interface CompanyChangeRequest {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
status: ChangeRequestStatus;
|
||||||
|
/** Proposed field values (the diff payload vs. the live company). */
|
||||||
|
snapshot: Record<string, unknown>;
|
||||||
|
documentFileIds: string[];
|
||||||
|
/** Staged business-license add/remove intents attached to this request. */
|
||||||
|
licenseChanges: LicenseChangeIntent[];
|
||||||
|
note: string | null;
|
||||||
|
submittedAt: string | null;
|
||||||
|
reviewedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
|
|||||||
|
|
||||||
export type InventoryAction =
|
export type InventoryAction =
|
||||||
| 'store'
|
| 'store'
|
||||||
| 'reserve'
|
|
||||||
| 'ready-for-loading'
|
| 'ready-for-loading'
|
||||||
| 'load'
|
| 'load'
|
||||||
| 'dispatch'
|
| 'dispatch'
|
||||||
@@ -59,7 +58,7 @@ export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | nu
|
|||||||
UNLOADED: 'store',
|
UNLOADED: 'store',
|
||||||
UNLOADED_AT_DJIBOUTI_PORT: null,
|
UNLOADED_AT_DJIBOUTI_PORT: null,
|
||||||
RECEIVED: 'store',
|
RECEIVED: 'store',
|
||||||
STORED: 'reserve',
|
STORED: 'ready-for-loading',
|
||||||
RESERVED: 'ready-for-loading',
|
RESERVED: 'ready-for-loading',
|
||||||
ARRIVED_AT_WAREHOUSE: null,
|
ARRIVED_AT_WAREHOUSE: null,
|
||||||
UNDER_INSPECTION: null,
|
UNDER_INSPECTION: null,
|
||||||
@@ -85,6 +84,11 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
|||||||
// Import goods skip storage; they need inspection before pickup.
|
// Import goods skip storage; they need inspection before pickup.
|
||||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||||
return 'store';
|
return 'store';
|
||||||
|
case 'STORED':
|
||||||
|
// Reserve is retired: a stored export item goes straight to loading prep
|
||||||
|
// once inspection passes. Import STORED is handled via the import queue.
|
||||||
|
if (isImport) return null;
|
||||||
|
return inspected ? 'ready-for-loading' : null;
|
||||||
case 'RESERVED':
|
case 'RESERVED':
|
||||||
// Export loading is gated on a passed inspection.
|
// Export loading is gated on a passed inspection.
|
||||||
return inspected ? 'ready-for-loading' : null;
|
return inspected ? 'ready-for-loading' : null;
|
||||||
@@ -589,12 +593,21 @@ export interface ImportUnloadedItem {
|
|||||||
customerTruckType: string | null;
|
customerTruckType: string | null;
|
||||||
customerTruckContainerNumber: string | null;
|
customerTruckContainerNumber: string | null;
|
||||||
customerTruckAssignedAt: string | null;
|
customerTruckAssignedAt: string | null;
|
||||||
|
hasAssignedTruck: boolean;
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseOrderReference: string | null;
|
releaseOrderReference: string | null;
|
||||||
handoverDocumentReference: string | null;
|
handoverDocumentReference: string | null;
|
||||||
handoverDocumentDate: string | null;
|
handoverDocumentDate: string | null;
|
||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional explicit storage location; blank → backend auto-allocates. */
|
||||||
|
export interface StoreInventoryPayload {
|
||||||
|
warehouseId?: string;
|
||||||
|
yardId?: string;
|
||||||
|
zoneId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportTrainItem {
|
export interface ImportTrainItem {
|
||||||
|
|||||||
@@ -8,6 +8,32 @@ export type ApiError = {
|
|||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend errors arrive as snake_case i18n-style codes (e.g.
|
||||||
|
* "unable_to_log_in"). Map the known ones to friendly copy and prettify
|
||||||
|
* anything else so raw codes never reach the UI. `code` stays raw for
|
||||||
|
* programmatic checks.
|
||||||
|
*/
|
||||||
|
const API_ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
unable_to_log_in: "Incorrect email or password.",
|
||||||
|
invalid_refresh_token: "Your session has expired. Please sign in again.",
|
||||||
|
session_expired: "Your session has expired. Please sign in again.",
|
||||||
|
session_not_found: "Your session has expired. Please sign in again.",
|
||||||
|
user_not_found: "No account found for these credentials.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/;
|
||||||
|
|
||||||
|
function humanizeApiMessage(raw: string): string {
|
||||||
|
const known = API_ERROR_MESSAGES[raw];
|
||||||
|
if (known) return known;
|
||||||
|
if (SNAKE_CASE_CODE.test(raw)) {
|
||||||
|
const text = raw.replaceAll("_", " ");
|
||||||
|
return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
export function extractApiError(err: unknown): ApiError {
|
export function extractApiError(err: unknown): ApiError {
|
||||||
if (err && typeof err === "object") {
|
if (err && typeof err === "object") {
|
||||||
const obj = err as Record<string, unknown>;
|
const obj = err as Record<string, unknown>;
|
||||||
@@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError {
|
|||||||
const statusCode = response.status as number | undefined;
|
const statusCode = response.status as number | undefined;
|
||||||
const data = response.data as Record<string, unknown> | undefined;
|
const data = response.data as Record<string, unknown> | undefined;
|
||||||
return {
|
return {
|
||||||
code: (data?.error as string) || (data?.message as string) || "api_error",
|
code: (data?.message as string) || (data?.error as string) || "api_error",
|
||||||
message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred",
|
message: humanizeApiMessage(
|
||||||
|
(data?.message as string) ||
|
||||||
|
(data?.error as string) ||
|
||||||
|
"An unexpected error occurred",
|
||||||
|
),
|
||||||
statusCode,
|
statusCode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import OnboardingResumeBanner, {
|
|||||||
} from "./components/onboarding/OnboardingResumeBanner";
|
} from "./components/onboarding/OnboardingResumeBanner";
|
||||||
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
||||||
import useAuth from "./hooks/useAuth";
|
import useAuth from "./hooks/useAuth";
|
||||||
|
import {
|
||||||
|
startTokenRefreshScheduler,
|
||||||
|
stopTokenRefreshScheduler,
|
||||||
|
} from "./utils/refreshScheduler";
|
||||||
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||||
import MyPortalPage from "./pages/MyPortalPage";
|
import MyPortalPage from "./pages/MyPortalPage";
|
||||||
import MySignaturePage from "./pages/MySignaturePage";
|
import MySignaturePage from "./pages/MySignaturePage";
|
||||||
@@ -212,7 +216,20 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, company, companyType, createProfileAndSwitch } = useAuth();
|
const { user, company, companyType, createProfileAndSwitch, isAuthenticated } =
|
||||||
|
useAuth();
|
||||||
|
|
||||||
|
// Keep the server session alive while a user is logged in. Runs after
|
||||||
|
// login, signup, and page-reload bootstrap alike.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
stopTokenRefreshScheduler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTokenRefreshScheduler();
|
||||||
|
return stopTokenRefreshScheduler;
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
const userEmail = user?.email;
|
const userEmail = user?.email;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ArrowRight, Clock } from "lucide-react";
|
import { Link } from "react-router-dom";
|
||||||
|
import { AlertTriangle, ArrowRight, Clock } from "lucide-react";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import type { OnboardingRequirements } from "@/services/companies.service";
|
import type { OnboardingRequirements } from "@/services/companies.service";
|
||||||
@@ -148,16 +149,74 @@ export default function OnboardingResumeBanner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shown once onboarding is submitted but the company's operational profiles are
|
* Post-onboarding review banner. Surfaces (in priority order):
|
||||||
* still being reviewed. Communicates that approval is per-profile and that
|
* 1. A pending profile-edit review — the whole account is locked until an admin
|
||||||
* bookings unlock as each profile is cleared. Self-hides when nothing is pending.
|
* approves the submitted changes.
|
||||||
|
* 2. A rejected profile-edit review — links to Settings to amend & resubmit.
|
||||||
|
* 3. Per-operational-profile approval — bookings unlock as each role clears.
|
||||||
|
* Self-hides when there's nothing outstanding.
|
||||||
*/
|
*/
|
||||||
export function AccountReviewBanner() {
|
export function AccountReviewBanner() {
|
||||||
const { company } = useAuth();
|
const { company, reviewStatus, reviewNote } = useAuth();
|
||||||
const profiles = company?.company?.companyProfiles ?? [];
|
const profiles = company?.company?.companyProfiles ?? [];
|
||||||
const pending = profiles.filter((p) => p.status === "pending");
|
const pending = profiles.filter((p) => p.status === "pending");
|
||||||
const approved = profiles.filter((p) => p.status === "active");
|
const approved = profiles.filter((p) => p.status === "active");
|
||||||
|
|
||||||
|
// 1. Profile-edit review pending — the account-wide lock.
|
||||||
|
if (reviewStatus === "pending") {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||||
|
<Clock size={18} />
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-semibold text-amber-900">
|
||||||
|
Your profile changes are under review
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-amber-800">
|
||||||
|
Editing and creating new contracts or bookings is paused until an
|
||||||
|
administrator approves your submitted changes.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Profile-edit review rejected — prompt to fix & resubmit.
|
||||||
|
if (reviewStatus === "rejected") {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-700">
|
||||||
|
<AlertTriangle size={18} />
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-semibold text-red-900">
|
||||||
|
Your recent changes were not approved
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-red-800">
|
||||||
|
{reviewNote
|
||||||
|
? `Reviewer note: ${reviewNote}`
|
||||||
|
: "Please update your details and resubmit for review."}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-transform hover:scale-[1.02]"
|
||||||
|
>
|
||||||
|
Review & resubmit
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Per-operational-profile approval (existing behaviour).
|
||||||
if (profiles.length === 0 || pending.length === 0) return null;
|
if (profiles.length === 0 || pending.length === 0) return null;
|
||||||
|
|
||||||
const pendingLabel = pending
|
const pendingLabel = pending
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Paperclip } from "lucide-react";
|
|||||||
import { SmartFileInput } from "@edr/ui-common";
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
import type { IFileUploadSetting } from "@edr/types/freight";
|
import type { IFileUploadSetting } from "@edr/types/freight";
|
||||||
|
|
||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import type { LicenseFile } from "@/services/companies.service";
|
import type { LicenseFile } from "@/services/companies.service";
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
@@ -107,10 +108,10 @@ export default function RoleLicenseStep({
|
|||||||
{hasExisting && (
|
{hasExisting && (
|
||||||
<Stack gap={4} mb="sm">
|
<Stack gap={4} mb="sm">
|
||||||
{profile.existingFiles.map((f) => (
|
{profile.existingFiles.map((f) => (
|
||||||
<Group key={f.url} gap={6} wrap="nowrap">
|
<Group key={f.id} gap={6} wrap="nowrap">
|
||||||
<Paperclip size={13} className="text-edr-muted" />
|
<Paperclip size={13} className="text-edr-muted" />
|
||||||
<Anchor
|
<Anchor
|
||||||
href={f.url}
|
href={fileViewUrl(f.id)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ export const URL_CONSTANTS = {
|
|||||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||||
PROFILE_LICENSE: (profileId: string) =>
|
PROFILE_LICENSE: (profileId: string) =>
|
||||||
`/api/companies/company-profiles/${profileId}/license`,
|
`/api/companies/company-profiles/${profileId}/license`,
|
||||||
|
PROFILE_LICENSE_FILE: (profileId: string, fileId: string) =>
|
||||||
|
`/api/companies/company-profiles/${profileId}/license/${fileId}`,
|
||||||
|
PROFILE_LICENSE_REPLACE: (profileId: string, fileId: string) =>
|
||||||
|
`/api/companies/company-profiles/${profileId}/license/${fileId}/replace`,
|
||||||
|
PROFILE_CHANGE_REQUEST: "/api/companies/profile/change-request",
|
||||||
|
PROFILE_REAPPLY: (profileId: string) =>
|
||||||
|
`/api/companies/company-profiles/${profileId}/reapply`,
|
||||||
},
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
|
|||||||
@@ -44,7 +44,21 @@ const useAuth = () => {
|
|||||||
api.companies.getInfo.queryOptions({
|
api.companies.getInfo.queryOptions({
|
||||||
enabled: !!authQuery.data?.id,
|
enabled: !!authQuery.data?.id,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 10 * 60 * 1000,
|
|
||||||
|
staleTime(query) {
|
||||||
|
// Fast-poll while anything is awaiting a backoffice decision: an
|
||||||
|
// unapproved role, or a pending profile-edit review. This surfaces
|
||||||
|
// approvals/rejections to the portal within a minute.
|
||||||
|
if (
|
||||||
|
query.state.data?.review?.status === "pending" ||
|
||||||
|
query.state.data?.company?.companyProfiles?.find(
|
||||||
|
(p) => p.status !== "active",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 60;
|
||||||
|
|
||||||
|
return 10 * 60 * 1000;
|
||||||
|
},
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -166,6 +180,14 @@ const useAuth = () => {
|
|||||||
const activeProfileStatus = activeProfile?.status ?? null;
|
const activeProfileStatus = activeProfile?.status ?? null;
|
||||||
const canBook = activeProfileStatus === "active";
|
const canBook = activeProfileStatus === "active";
|
||||||
|
|
||||||
|
// Profile-edit review: while a change request is pending the customer is
|
||||||
|
// locked out of editing and of creating new contracts/bookings; a rejected
|
||||||
|
// request surfaces the reviewer note so they can amend and resubmit.
|
||||||
|
const review = companyInfo?.review ?? null;
|
||||||
|
const reviewStatus = review?.status ?? null;
|
||||||
|
const reviewNote = review?.note ?? null;
|
||||||
|
const isUnderReview = reviewStatus === "pending";
|
||||||
|
|
||||||
/** Refetch everything scoped to the active operational profile. */
|
/** Refetch everything scoped to the active operational profile. */
|
||||||
const invalidateScopedData = async () => {
|
const invalidateScopedData = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -179,9 +201,7 @@ const useAuth = () => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const switchMode = async (
|
const switchMode = async (type: ProfileTypeValue): Promise<Result<void>> => {
|
||||||
type: ProfileTypeValue,
|
|
||||||
): Promise<Result<void>> => {
|
|
||||||
try {
|
try {
|
||||||
await api.companies.setActiveMode.call({ type });
|
await api.companies.setActiveMode.call({ type });
|
||||||
await invalidateScopedData();
|
await invalidateScopedData();
|
||||||
@@ -207,6 +227,19 @@ const useAuth = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Resubmit a rejected operational role for approval, then refresh. */
|
||||||
|
const reapplyProfile = async (
|
||||||
|
profileId: string,
|
||||||
|
): Promise<Result<void>> => {
|
||||||
|
try {
|
||||||
|
await api.companies.reapplyProfile.call({ profileId });
|
||||||
|
await invalidateScopedData();
|
||||||
|
return { success: true, data: undefined };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: extractApiError(err) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
await api.auth.logout.call();
|
await api.auth.logout.call();
|
||||||
@@ -239,10 +272,14 @@ const useAuth = () => {
|
|||||||
companyType,
|
companyType,
|
||||||
companyStatus,
|
companyStatus,
|
||||||
isCompanyApproved,
|
isCompanyApproved,
|
||||||
|
reviewStatus,
|
||||||
|
reviewNote,
|
||||||
|
isUnderReview,
|
||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
onboardingStep,
|
onboardingStep,
|
||||||
switchMode,
|
switchMode,
|
||||||
createProfileAndSwitch,
|
createProfileAndSwitch,
|
||||||
|
reapplyProfile,
|
||||||
login,
|
login,
|
||||||
signup,
|
signup,
|
||||||
setPassword,
|
setPassword,
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
|
Fieldset,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -17,9 +19,11 @@ import {
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Building2,
|
Building2,
|
||||||
|
Clock,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
Globe,
|
Globe,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -225,6 +229,9 @@ export default function SettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reviewStatus = profile.reviewStatus ?? null;
|
||||||
|
const locked = reviewStatus === "pending";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" px="lg" py="xl">
|
<Container size="xl" px="lg" py="xl">
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
@@ -239,6 +246,39 @@ export default function SettingsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{reviewStatus === "pending" && (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
icon={<Clock size={18} />}
|
||||||
|
title="Changes submitted for review"
|
||||||
|
>
|
||||||
|
Your recent changes are awaiting administrator approval. Editing is
|
||||||
|
disabled until the review is complete — you'll be notified once it's
|
||||||
|
approved or if any changes are requested.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{reviewStatus === "rejected" && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
icon={<AlertTriangle size={18} />}
|
||||||
|
title="Changes were not approved"
|
||||||
|
>
|
||||||
|
<Stack gap={4}>
|
||||||
|
{profile.reviewNote && (
|
||||||
|
<Text size="sm">
|
||||||
|
<strong>Reviewer note:</strong> {profile.reviewNote}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="sm">
|
||||||
|
Please update the details below and save again to resubmit for
|
||||||
|
review.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={(value) => value && setTab(value as SettingsTab)}
|
onChange={(value) => value && setTab(value as SettingsTab)}
|
||||||
@@ -269,20 +309,33 @@ export default function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
|
{/* While a change request is pending, every panel's inputs + submit
|
||||||
|
buttons are disabled via the native fieldset; tab switching stays
|
||||||
|
enabled so the customer can still review what they submitted. */}
|
||||||
<Tabs.Panel value="company">
|
<Tabs.Panel value="company">
|
||||||
<TabCompanyProfile mode="edit" profile={profile} />
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
|
<TabCompanyProfile mode="edit" profile={profile} />
|
||||||
|
</Fieldset>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="contact">
|
<Tabs.Panel value="contact">
|
||||||
<TabContactPerson profile={profile} mode="edit" />
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
|
<TabContactPerson profile={profile} mode="edit" />
|
||||||
|
</Fieldset>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="gm">
|
<Tabs.Panel value="gm">
|
||||||
<TabGeneralManager profile={profile} mode="edit" />
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
|
<TabGeneralManager profile={profile} mode="edit" />
|
||||||
|
</Fieldset>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="poa">
|
<Tabs.Panel value="poa">
|
||||||
<TabPowerOfAttorney profile={profile} mode="edit" />
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
|
<TabPowerOfAttorney profile={profile} mode="edit" />
|
||||||
|
</Fieldset>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="documents">
|
<Tabs.Panel value="documents">
|
||||||
<TabDocuments profile={profile} mode="edit" />
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
|
<TabDocuments profile={profile} mode="edit" />
|
||||||
|
</Fieldset>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -242,270 +242,259 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<AuthShell
|
||||||
tagline= "Smart Freight Operations"
|
tagline="Smart Freight Operations"
|
||||||
taglineBody = "Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col" >
|
<div className="flex w-full flex-col">
|
||||||
{ stage === "form" ? (
|
{stage === "form" ? (
|
||||||
<form
|
<form
|
||||||
onSubmit= { handleSubmit(requestOtp) }
|
onSubmit={handleSubmit(requestOtp)}
|
||||||
className = "flex w-full flex-col"
|
className="flex w-full flex-col"
|
||||||
>
|
>
|
||||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5" >
|
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl" >
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
Create account
|
Create account
|
||||||
</h1>
|
</h1>
|
||||||
< p className = "text-sm leading-relaxed text-gray-500" >
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
Register to access EDR Freight services.
|
Register to access EDR Freight services.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
< Stack gap = "sm" >
|
<Stack gap="sm">
|
||||||
<SimpleGrid cols={ { base: 1, sm: 2 } } spacing = "md" >
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="First name"
|
label="First name"
|
||||||
placeholder = "John"
|
placeholder="John"
|
||||||
required
|
required
|
||||||
disabled = { sending }
|
disabled={sending}
|
||||||
error = { errors.firstName?.en?.message }
|
error={errors.firstName?.en?.message}
|
||||||
{...register("firstName.en") }
|
{...register("firstName.en")}
|
||||||
/>
|
/>
|
||||||
< TextInput
|
<TextInput
|
||||||
label = "Last name"
|
label="Last name"
|
||||||
placeholder = "Doe"
|
placeholder="Doe"
|
||||||
required
|
required
|
||||||
disabled = { sending }
|
disabled={sending}
|
||||||
error = { errors.lastName?.en?.message }
|
error={errors.lastName?.en?.message}
|
||||||
{...register("lastName.en") }
|
{...register("lastName.en")}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
< TextInput
|
<TextInput
|
||||||
label = "Email"
|
label="Email"
|
||||||
type = "email"
|
type="email"
|
||||||
placeholder = "john@example.com"
|
placeholder="john@example.com"
|
||||||
required
|
required
|
||||||
disabled = { sending }
|
disabled={sending}
|
||||||
error = { errors.email?.message }
|
error={errors.email?.message}
|
||||||
{...register("email") }
|
{...register("email")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
< ControlledPhoneField
|
<ControlledPhoneField
|
||||||
control = { control }
|
control={control}
|
||||||
name = "phone"
|
name="phone"
|
||||||
label = "Phone"
|
label="Phone"
|
||||||
required
|
required
|
||||||
disabled = { sending }
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-1.5" >
|
<div className="space-y-1.5">
|
||||||
<Text size="sm" fw = { 500} c = "edr-text" >
|
<Text size="sm" fw={500} c="edr-text">
|
||||||
Send verification code via
|
Send verification code via
|
||||||
</Text>
|
</Text>
|
||||||
< SegmentedControl
|
<SegmentedControl
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled = { sending }
|
disabled={sending}
|
||||||
value = { channel }
|
value={channel}
|
||||||
onChange = {(v) => setChannel(v as OtpChannel)
|
onChange={(v) => setChannel(v as OtpChannel)}
|
||||||
}
|
data={[
|
||||||
data = {
|
{
|
||||||
[
|
value: "phone",
|
||||||
{
|
label: (
|
||||||
value: "phone",
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
label: (
|
<Smartphone size={14} /> Phone
|
||||||
<span className= "flex items-center justify-center gap-1.5" >
|
</span>
|
||||||
<Smartphone size={ 14} /> Phone
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "email",
|
value: "email",
|
||||||
label: (
|
label: (
|
||||||
<span className= "flex items-center justify-center gap-1.5" >
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
<Mail size={ 14 } /> Email
|
<Mail size={14} /> Email
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
< div >
|
<div>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Password"
|
label="Password"
|
||||||
placeholder = "Create a strong password"
|
placeholder="Create a strong password"
|
||||||
required
|
required
|
||||||
disabled = { sending }
|
disabled={sending}
|
||||||
error = { errors.password?.message }
|
error={errors.password?.message}
|
||||||
{...register("password") }
|
{...register("password")}
|
||||||
/>
|
/>
|
||||||
{
|
{passwordValue.length > 0 ? (
|
||||||
passwordValue.length > 0 ? (
|
<div className="mt-2 space-y-1">
|
||||||
<div className= "mt-2 space-y-1" >
|
{passwordRequirements.map((req) => {
|
||||||
{
|
const met = req.test(passwordValue);
|
||||||
passwordRequirements.map((req) => {
|
return (
|
||||||
const met = req.test(passwordValue);
|
<div
|
||||||
return (
|
key={req.label}
|
||||||
<div
|
className="flex items-center gap-2"
|
||||||
key= { req.label }
|
>
|
||||||
className = "flex items-center gap-2"
|
<span
|
||||||
>
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
|
||||||
<span
|
? "bg-primary text-primary-foreground"
|
||||||
className={
|
: "bg-gray-200 text-gray-500"
|
||||||
`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
|
}`}
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-gray-200 text-gray-500"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{
|
{met ? (
|
||||||
met?(
|
<Check className="h-2.5 w-2.5" />
|
||||||
<Check className = "h-2.5 w-2.5" />
|
) : (
|
||||||
): (
|
<X className="h-2.5 w-2.5" />
|
||||||
<X className = "h-2.5 w-2.5" />
|
)}
|
||||||
)
|
</span>
|
||||||
}
|
<span
|
||||||
</span>
|
className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}
|
||||||
< span
|
|
||||||
className = {`text-xs ${met ? "text-primary" : "text-gray-500"}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{ req.label }
|
{req.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
< PasswordInput
|
<PasswordInput
|
||||||
label = "Confirm password"
|
label="Confirm password"
|
||||||
placeholder = "Re-enter your password"
|
placeholder="Re-enter your password"
|
||||||
required
|
required
|
||||||
disabled = { sending }
|
disabled={sending}
|
||||||
error = { errors.confirmPassword?.message }
|
error={errors.confirmPassword?.message}
|
||||||
{...register("confirmPassword") }
|
{...register("confirmPassword")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{error ? (
|
||||||
error ? (
|
<Alert
|
||||||
<Alert
|
color="red"
|
||||||
color= "red"
|
variant="light"
|
||||||
variant = "light"
|
icon={<AlertCircle size={18} />}
|
||||||
icon = {< AlertCircle size = { 18} />}
|
|
||||||
>
|
>
|
||||||
{ error }
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
color = "edr-green"
|
color="edr-green"
|
||||||
fullWidth
|
fullWidth
|
||||||
loading = { sending }
|
loading={sending}
|
||||||
rightSection = {!sending ? <ArrowRight size={ 16 } /> : undefined}
|
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
< p className = "text-center text-sm text-gray-500" >
|
<p className="text-center text-sm text-gray-500">
|
||||||
Already have an account ? { " "}
|
Already have an account ?{" "}
|
||||||
< button
|
<button
|
||||||
type = "button"
|
type="button"
|
||||||
onClick = {() => navigate("/login")}
|
onClick={() => navigate("/login")}
|
||||||
className = "font-semibold text-primary hover:underline"
|
className="font-semibold text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
</p>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<Stack gap= "md" >
|
|
||||||
<div className="mb-1 flex justify-center" >
|
|
||||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary" >
|
|
||||||
<ShieldCheck size={ 22 } />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
< div className = "space-y-1.5 text-center" >
|
|
||||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl" >
|
|
||||||
Verify your { otpChannel === "email" ? "email" : "phone" }
|
|
||||||
</h1>
|
|
||||||
< p className = "text-sm leading-relaxed text-gray-500" >
|
|
||||||
We sent a 6 - digit code to{ " " }
|
|
||||||
<span className="font-medium text-gray-700" >
|
|
||||||
{ otpChannel === "email"
|
|
||||||
? maskEmail(pendingData?.email ?? "")
|
|
||||||
: maskPhone(pendingData?.phone ?? "")}
|
|
||||||
</span>
|
|
||||||
.Enter it to finish creating your account.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</Stack>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
<div className="mb-1 flex justify-center">
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<ShieldCheck size={22} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 text-center">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
|
Verify your {otpChannel === "email" ? "email" : "phone"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
|
We sent a 6 - digit code to{" "}
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
{otpChannel === "email"
|
||||||
|
? maskEmail(pendingData?.email ?? "")
|
||||||
|
: maskPhone(pendingData?.phone ?? "")}
|
||||||
|
</span>
|
||||||
|
.Enter it to finish creating your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{
|
{otpError ? (
|
||||||
otpError ? (
|
<Alert
|
||||||
<Alert
|
color="red"
|
||||||
color= "red"
|
variant="light"
|
||||||
variant = "light"
|
icon={<AlertCircle size={18} />}
|
||||||
icon = {< AlertCircle size = { 18} />}
|
|
||||||
>
|
>
|
||||||
{ otpError }
|
{otpError}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Stack gap={ 6 } align = "center" >
|
<Stack gap={6} align="center">
|
||||||
<Text size="sm" fw = { 500} c = "edr-text" >
|
<Text size="sm" fw={500} c="edr-text">
|
||||||
Verification code
|
Verification code
|
||||||
</Text>
|
</Text>
|
||||||
< PinInput
|
<PinInput
|
||||||
length = { 6}
|
length={6}
|
||||||
type = "number"
|
type="number"
|
||||||
oneTimeCode
|
oneTimeCode
|
||||||
value = { otpCode }
|
value={otpCode}
|
||||||
placeholder = "0"
|
placeholder="0"
|
||||||
disabled = { verifying }
|
disabled={verifying}
|
||||||
styles = {{ input: { textAlign: "center" } }}
|
styles={{ input: { textAlign: "center" } }}
|
||||||
onChange = { setOtpCode }
|
onChange={setOtpCode}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
< Button
|
<Button
|
||||||
color = "edr-green"
|
color="edr-green"
|
||||||
fullWidth
|
fullWidth
|
||||||
loading = { verifying }
|
loading={verifying}
|
||||||
disabled = { verifying || otpCode.trim().length !== 6}
|
disabled={verifying || otpCode.trim().length !== 6}
|
||||||
onClick = { confirmOtp }
|
onClick={confirmOtp}
|
||||||
>
|
>
|
||||||
Verify & amp; create account
|
Verify & create account
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
< div className = "flex items-center justify-between" >
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color = "gray"
|
color="gray"
|
||||||
leftSection = {< ArrowLeft size = { 14} />}
|
leftSection={<ArrowLeft size={14} />}
|
||||||
disabled = { sending || verifying}
|
disabled={sending || verifying}
|
||||||
onClick = {() => {
|
onClick={() => {
|
||||||
setStage("form");
|
setStage("form");
|
||||||
setOtpError(null);
|
setOtpError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
< Button
|
<Button
|
||||||
variant = "subtle"
|
variant="subtle"
|
||||||
color = "edr-green"
|
color="edr-green"
|
||||||
leftSection = {< RotateCw size = { 14} />}
|
leftSection={<RotateCw size={14} />}
|
||||||
disabled = { resendIn > 0 || sending || verifying}
|
disabled={resendIn > 0 || sending || verifying}
|
||||||
onClick = { resendOtp }
|
onClick={resendOtp}
|
||||||
>
|
>
|
||||||
{ resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ContainersCard } from "./components/ContainersCard";
|
|||||||
import { ContractCard } from "./components/ContractCard";
|
import { ContractCard } from "./components/ContractCard";
|
||||||
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
||||||
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
||||||
|
import { MileSummaryCard } from "./components/MileSummaryCard";
|
||||||
import { BodyGrid, PageShell } from "./components/layout";
|
import { BodyGrid, PageShell } from "./components/layout";
|
||||||
import {
|
import {
|
||||||
CancelledBanner,
|
CancelledBanner,
|
||||||
@@ -108,6 +109,7 @@ export function ReadonlyBookingView({
|
|||||||
: status === "SELECTED_FOR_BATCH");
|
: status === "SELECTED_FOR_BATCH");
|
||||||
const canApproveDelivery =
|
const canApproveDelivery =
|
||||||
status === "COMPLETED" ||
|
status === "COMPLETED" ||
|
||||||
|
Boolean(booking.handoverAwaitingSignature) ||
|
||||||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
|
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
|
||||||
const usesCustomerTruck =
|
const usesCustomerTruck =
|
||||||
booking.tradeDirection === "IMPORT"
|
booking.tradeDirection === "IMPORT"
|
||||||
@@ -219,6 +221,8 @@ export function ReadonlyBookingView({
|
|||||||
<WarehousePaymentsSection bookingId={booking.id} />
|
<WarehousePaymentsSection bookingId={booking.id} />
|
||||||
|
|
||||||
<ActivityCard booking={booking} />
|
<ActivityCard booking={booking} />
|
||||||
|
|
||||||
|
<MileSummaryCard booking={booking} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react";
|
import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
@@ -63,44 +63,59 @@ export function CustomerTruckAssignmentCard({
|
|||||||
const [driverName, setDriverName] = useState("");
|
const [driverName, setDriverName] = useState("");
|
||||||
const [truckType, setTruckType] = useState("");
|
const [truckType, setTruckType] = useState("");
|
||||||
const [containers, setContainers] = useState<string[]>([]);
|
const [containers, setContainers] = useState<string[]>([]);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Container numbers on the booking that aren't already loaded onto a truck.
|
// Container numbers on the booking that aren't already loaded onto a truck.
|
||||||
const assignedNumbers = new Set(
|
const assignedNumbers = new Set(
|
||||||
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
|
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
|
||||||
);
|
);
|
||||||
|
// When editing a truck, its own containers stay selectable.
|
||||||
|
const editingOwn = new Set(
|
||||||
|
(trucks.find((t) => t.id === editingId)?.containers ?? []).map((c) => c.containerNumber),
|
||||||
|
);
|
||||||
const availableContainers = (booking.containerNumbers ?? []).filter(
|
const availableContainers = (booking.containerNumbers ?? []).filter(
|
||||||
(n) => !assignedNumbers.has(n),
|
(n) => !assignedNumbers.has(n) || editingOwn.has(n),
|
||||||
);
|
);
|
||||||
|
|
||||||
// EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't —
|
// Both import and export specify the containers each truck carries.
|
||||||
// staff register + weigh what was loaded when the truck leaves.
|
|
||||||
const isExport = booking.tradeDirection === "EXPORT";
|
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setPlateNumber("");
|
setPlateNumber("");
|
||||||
setDriverName("");
|
setDriverName("");
|
||||||
setTruckType("");
|
setTruckType("");
|
||||||
setContainers([]);
|
setContainers([]);
|
||||||
|
setEditingId(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (t: Freight.ICustomerTruck) => {
|
||||||
|
setPlateNumber(t.plateNumber ?? "");
|
||||||
|
setDriverName(t.driverName ?? "");
|
||||||
|
setTruckType(t.truckType ?? "");
|
||||||
|
setContainers((t.containers ?? []).map((c) => c.containerNumber));
|
||||||
|
setEditingId(t.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => {
|
||||||
customerTrucksService.add(booking.id, {
|
const payload = {
|
||||||
truckPlateNumber: plateNumber.trim().toUpperCase(),
|
truckPlateNumber: plateNumber.trim().toUpperCase(),
|
||||||
driverName: driverName.trim(),
|
driverName: driverName.trim(),
|
||||||
truckType: truckType.trim(),
|
truckType: truckType.trim(),
|
||||||
// Import: containers are registered + weighed on departure, not here.
|
containerNumbers: containers,
|
||||||
containerNumbers: isExport ? containers : [],
|
};
|
||||||
}),
|
return editingId
|
||||||
|
? customerTrucksService.update(booking.id, editingId, payload)
|
||||||
|
: customerTrucksService.add(booking.id, payload);
|
||||||
|
},
|
||||||
onSuccess: (list) => {
|
onSuccess: (list) => {
|
||||||
queryClient.setQueryData(trucksKey, list);
|
queryClient.setQueryData(trucksKey, list);
|
||||||
|
toast.success(editingId ? "Truck updated" : "Truck added");
|
||||||
resetForm();
|
resetForm();
|
||||||
onAssigned();
|
onAssigned();
|
||||||
toast.success("Truck added");
|
|
||||||
},
|
},
|
||||||
onError: (e) => setError(errorMessage(e, "Could not add truck")),
|
onError: (e) => setError(errorMessage(e, editingId ? "Could not update truck" : "Could not add truck")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeMutation = useMutation({
|
const removeMutation = useMutation({
|
||||||
@@ -123,7 +138,7 @@ export function CustomerTruckAssignmentCard({
|
|||||||
setError("Plate number, driver name and truck type are required.");
|
setError("Plate number, driver name and truck type are required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isExport && (containers.length < 1 || containers.length > 2)) {
|
if (containers.length < 1 || containers.length > 2) {
|
||||||
setError("Select 1 or 2 container numbers for this truck.");
|
setError("Select 1 or 2 container numbers for this truck.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -187,15 +202,25 @@ export function CustomerTruckAssignmentCard({
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
{!t.arrivedAt && (
|
{!t.arrivedAt && (
|
||||||
<ActionIcon
|
<Group gap={4} wrap="nowrap">
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="red"
|
variant="subtle"
|
||||||
aria-label="Remove truck"
|
color="blue"
|
||||||
onClick={() => removeMutation.mutate(t.id)}
|
aria-label="Edit truck"
|
||||||
loading={removeMutation.isPending}
|
onClick={() => startEdit(t)}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Pencil size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
aria-label="Remove truck"
|
||||||
|
onClick={() => removeMutation.mutate(t.id)}
|
||||||
|
loading={removeMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
))
|
))
|
||||||
@@ -207,10 +232,10 @@ export function CustomerTruckAssignmentCard({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add-truck form. Export needs unassigned containers; import always allows another truck. */}
|
{/* Add-truck form — both directions assign the containers each truck carries. */}
|
||||||
{(isExport ? availableContainers.length > 0 : true) ? (
|
{availableContainers.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Divider label="Add a truck" labelPosition="center" />
|
<Divider label={editingId ? "Edit truck" : "Add a truck"} labelPosition="center" />
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Truck Plate Number"
|
label="Truck Plate Number"
|
||||||
@@ -231,28 +256,32 @@ export function CustomerTruckAssignmentCard({
|
|||||||
value={truckType || null}
|
value={truckType || null}
|
||||||
onChange={(value) => setTruckType(value ?? "")}
|
onChange={(value) => setTruckType(value ?? "")}
|
||||||
/>
|
/>
|
||||||
{isExport && (
|
<MultiSelect
|
||||||
<MultiSelect
|
label="Containers to load"
|
||||||
label="Containers to load (1–2)"
|
description="20ft: up to 2 per truck · 40ft: 1 per truck"
|
||||||
required
|
required
|
||||||
placeholder="Select container numbers"
|
placeholder="Select container numbers"
|
||||||
data={availableContainers}
|
data={availableContainers}
|
||||||
value={containers}
|
value={containers}
|
||||||
onChange={setContainers}
|
onChange={setContainers}
|
||||||
maxValues={2}
|
maxValues={2}
|
||||||
searchable
|
searchable
|
||||||
nothingFoundMessage="No unassigned containers"
|
nothingFoundMessage="No unassigned containers"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
|
{editingId && (
|
||||||
|
<Button variant="default" onClick={resetForm} disabled={addMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
leftSection={<Plus size={16} />}
|
leftSection={editingId ? <Pencil size={16} /> : <Plus size={16} />}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
onClick={submitAdd}
|
onClick={submitAdd}
|
||||||
loading={addMutation.isPending}
|
loading={addMutation.isPending}
|
||||||
>
|
>
|
||||||
Add truck
|
{editingId ? "Save changes" : "Add truck"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import type {
|
||||||
|
MileLegSummary,
|
||||||
|
MileVehicleSummary,
|
||||||
|
} from "@/services/bookings.service";
|
||||||
|
|
||||||
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const s = status.toUpperCase();
|
||||||
|
const done = s.includes("DELIVER") || s.includes("COMPLET") || s.includes("PAID");
|
||||||
|
const active = s.includes("TRANSIT") || s.includes("PROGRESS") || s.includes("ASSIGN");
|
||||||
|
const dot = done ? "#0EA371" : active ? "#2563EB" : "#94A3B8";
|
||||||
|
const color = done ? "#0A6F4D" : active ? "#1E40AF" : "#475569";
|
||||||
|
const bg = done ? "#ECF6F1" : active ? "#EAF1FE" : "#F1F4F7";
|
||||||
|
const border = done ? "#CDEBDD" : active ? "#CFDDFB" : "#E1E7EE";
|
||||||
|
const label = status
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, (m) => m.toUpperCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
component="span"
|
||||||
|
gap={6}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: bg,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
style={{ width: 6, height: 6, borderRadius: 999, backgroundColor: dot }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VehicleRow({ v }: { v: MileVehicleSummary }) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (v.driverName) parts.push(v.driverName);
|
||||||
|
if (v.containerNumber) parts.push(`Container ${v.containerNumber}`);
|
||||||
|
if (v.distanceKm != null) parts.push(`${v.distanceKm} km`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-start"
|
||||||
|
wrap="nowrap"
|
||||||
|
py={8}
|
||||||
|
style={{ borderTop: "1px solid #F2F5F8" }}
|
||||||
|
>
|
||||||
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||||
|
<Text fz="13px" fw={700} c="#10202F">
|
||||||
|
{v.plate || v.code || "Vehicle"}
|
||||||
|
</Text>
|
||||||
|
{parts.length > 0 && (
|
||||||
|
<Text fz="12px" c="#6B7C8E">
|
||||||
|
{parts.join(" · ")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{v.code && v.plate && (
|
||||||
|
<Text fz="12px" c="#9AA8B5" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{v.code}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegBlock({
|
||||||
|
title,
|
||||||
|
leg,
|
||||||
|
address,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
leg: MileLegSummary | null;
|
||||||
|
address?: string | null;
|
||||||
|
}) {
|
||||||
|
const fmtMoney = (n: number | null, currency: string) =>
|
||||||
|
n == null
|
||||||
|
? null
|
||||||
|
: `${currency} ${Number(n).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" align="center" pb={8}>
|
||||||
|
<Text fz="13px" fw={700} c="#10202F">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{leg ? (
|
||||||
|
<StatusPill status={leg.status} />
|
||||||
|
) : (
|
||||||
|
<StatusPill status="AWAITING_ASSIGNMENT" />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{address && (
|
||||||
|
<Text fz="12px" c="#6B7C8E" pb={4}>
|
||||||
|
{title.startsWith("First") ? "Pickup" : "Delivery"}:{" "}
|
||||||
|
<b style={{ color: "#10202F" }}>{address}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!leg ? (
|
||||||
|
<Text fz="12px" c="#9AA8B5" fs="italic" py={6}>
|
||||||
|
Requested — a vehicle and driver will be assigned soon.
|
||||||
|
</Text>
|
||||||
|
) : leg.vehicles.length > 0 ? (
|
||||||
|
<Box>
|
||||||
|
{leg.vehicles.map((v, i) => (
|
||||||
|
<VehicleRow key={`${v.plate ?? v.code ?? "v"}-${i}`} v={v} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Text fz="12px" c="#9AA8B5" fs="italic" py={6}>
|
||||||
|
No vehicle assigned yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{leg && (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
pt={8}
|
||||||
|
mt={4}
|
||||||
|
style={{ borderTop: "1px solid #F2F5F8" }}
|
||||||
|
>
|
||||||
|
<Group gap={16}>
|
||||||
|
{leg.exactKm != null && (
|
||||||
|
<Text fz="12px" c="#6B7C8E">
|
||||||
|
Total distance{" "}
|
||||||
|
<b style={{ color: "#10202F" }}>{leg.exactKm} km</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{leg.remainingPayment != null && leg.remainingPayment > 0 && (
|
||||||
|
<Text fz="12px" c="#6B7C8E">
|
||||||
|
Balance{" "}
|
||||||
|
<b style={{ color: "#10202F" }}>
|
||||||
|
{fmtMoney(leg.remainingPayment, leg.currency)}
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{leg.invoiced && (
|
||||||
|
<Text fz="11px" fw={700} c="#0A6F4D">
|
||||||
|
Invoiced
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["booking-mile-summary", booking.id],
|
||||||
|
queryFn: () => bookingsService.mileSummary(booking.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstLeg = data?.firstMile ?? null;
|
||||||
|
const lastLeg = data?.lastMile ?? null;
|
||||||
|
|
||||||
|
// The backend doesn't persist an "enabled" flag — the presence of a
|
||||||
|
// pickup/delivery address is the request signal. Also show a leg once its
|
||||||
|
// record exists, regardless of address.
|
||||||
|
const showFirst = !!booking.firstMilePickupAddress || !!firstLeg;
|
||||||
|
const showLast = !!booking.lastMileDeliveryAddress || !!lastLeg;
|
||||||
|
|
||||||
|
if (!showFirst && !showLast) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard p={22}>
|
||||||
|
<Box pb={12}>
|
||||||
|
<CardTitle>First & Last Mile</CardTitle>
|
||||||
|
</Box>
|
||||||
|
<Stack gap={20}>
|
||||||
|
{showFirst && (
|
||||||
|
<LegBlock
|
||||||
|
title="First mile"
|
||||||
|
leg={firstLeg}
|
||||||
|
address={booking.firstMilePickupAddress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showLast && (
|
||||||
|
<LegBlock
|
||||||
|
title="Last mile"
|
||||||
|
leg={lastLeg}
|
||||||
|
address={booking.lastMileDeliveryAddress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Button, type ButtonProps } from "@mantine/core";
|
import { Button, type ButtonProps } from "@mantine/core";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
import type { MouseEvent } from "react";
|
import { type MouseEvent, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { ApproveDeliveryModal } from "./ApproveDeliveryModal";
|
||||||
|
|
||||||
type ApproveDeliveryButtonProps = ButtonProps & {
|
type ApproveDeliveryButtonProps = ButtonProps & {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
@@ -13,25 +10,6 @@ type ApproveDeliveryButtonProps = ButtonProps & {
|
|||||||
onApproved?: () => void;
|
onApproved?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorMessage = (error: unknown) => {
|
|
||||||
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
|
||||||
?.response?.data;
|
|
||||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
|
||||||
if (data?.message) return data.message;
|
|
||||||
return error instanceof Error ? error.message : "Could not approve delivery";
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadBlob = (blob: Blob, filename: string) => {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ApproveDeliveryButton({
|
export function ApproveDeliveryButton({
|
||||||
bookingId,
|
bookingId,
|
||||||
stopPropagation,
|
stopPropagation,
|
||||||
@@ -40,56 +18,31 @@ export function ApproveDeliveryButton({
|
|||||||
variant = "filled",
|
variant = "filled",
|
||||||
...props
|
...props
|
||||||
}: ApproveDeliveryButtonProps) {
|
}: ApproveDeliveryButtonProps) {
|
||||||
const navigate = useNavigate();
|
const [opened, setOpened] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions());
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
...api.bookings.approveDelivery.mutationOptions(),
|
|
||||||
onSuccess: async (result) => {
|
|
||||||
try {
|
|
||||||
const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId });
|
|
||||||
downloadBlob(blob, `handover-${bookingId}.pdf`);
|
|
||||||
toast.success("Delivery approved and signed handover downloaded");
|
|
||||||
} catch {
|
|
||||||
toast.success("Delivery approved and handover signed");
|
|
||||||
toast.error("Signed handover document could not be downloaded");
|
|
||||||
}
|
|
||||||
await Promise.all([
|
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
|
||||||
]);
|
|
||||||
onApproved?.();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const message = errorMessage(error);
|
|
||||||
toast.error(message);
|
|
||||||
if (message.toLowerCase().includes("save your signature")) {
|
|
||||||
navigate("/signature");
|
|
||||||
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
|
||||||
navigate("/billing");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
if (stopPropagation) event.stopPropagation();
|
if (stopPropagation) event.stopPropagation();
|
||||||
mutation.mutate({ id: bookingId });
|
setOpened(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
{...props}
|
<Button
|
||||||
size={size}
|
{...props}
|
||||||
variant={variant}
|
size={size}
|
||||||
color="edr-green"
|
variant={variant}
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
color="edr-green"
|
||||||
loading={mutation.isPending || handoverMutation.isPending}
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
Approve delivery
|
Approve delivery
|
||||||
</Button>
|
</Button>
|
||||||
|
<ApproveDeliveryModal
|
||||||
|
bookingId={bookingId}
|
||||||
|
opened={opened}
|
||||||
|
onClose={() => setOpened(false)}
|
||||||
|
onApproved={onApproved}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { CheckCircle2, Info } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
|
||||||
|
type ApproveDeliveryModalProps = {
|
||||||
|
bookingId: string;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onApproved?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown) => {
|
||||||
|
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
||||||
|
?.response?.data;
|
||||||
|
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||||
|
if (data?.message) return data.message;
|
||||||
|
return error instanceof Error ? error.message : "Could not approve delivery";
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBlob = (blob: Blob, filename: string) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve-delivery flow: open the handover document for the customer to review,
|
||||||
|
* then apply their saved signature (approve) and hand back the signed PDF.
|
||||||
|
*/
|
||||||
|
export function ApproveDeliveryModal({
|
||||||
|
bookingId,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onApproved,
|
||||||
|
}: ApproveDeliveryModalProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: docBlob,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["booking-handover-doc", bookingId],
|
||||||
|
queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
|
||||||
|
enabled: opened && Boolean(bookingId),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!docBlob) {
|
||||||
|
setPdfUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(docBlob);
|
||||||
|
setPdfUrl(url);
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [docBlob]);
|
||||||
|
|
||||||
|
const handoverMutation = useMutation(
|
||||||
|
api.bookings.downloadHandoverDocument.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const approve = useMutation({
|
||||||
|
...api.bookings.approveDelivery.mutationOptions(),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
try {
|
||||||
|
const signed = await handoverMutation.mutateAsync({
|
||||||
|
inventoryId: result.inventoryId,
|
||||||
|
});
|
||||||
|
downloadBlob(signed, `handover-${bookingId}.pdf`);
|
||||||
|
toast.success("Delivery approved and signed handover downloaded");
|
||||||
|
} catch {
|
||||||
|
toast.success("Delivery approved and handover signed");
|
||||||
|
toast.error("Signed handover document could not be downloaded");
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.bookings.get.queryKey({ id: bookingId }),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
||||||
|
]);
|
||||||
|
onApproved?.();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
toast.error(message);
|
||||||
|
if (message.toLowerCase().includes("save your signature")) {
|
||||||
|
onClose();
|
||||||
|
navigate("/signature");
|
||||||
|
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
||||||
|
onClose();
|
||||||
|
navigate("/billing");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const busy = approve.isPending || handoverMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Approve delivery — review & sign the handover"
|
||||||
|
size="xl"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert color="blue" variant="light" icon={<Info size={16} />}>
|
||||||
|
<Text size="sm">
|
||||||
|
Review the handover document below. Approving applies your saved signature
|
||||||
|
and confirms you received the goods.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading handover document…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : isError || !pdfUrl ? (
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
Could not load the handover document. It may not be generated yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
title="Handover document"
|
||||||
|
src={pdfUrl}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "60vh",
|
||||||
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
loading={busy}
|
||||||
|
disabled={isLoading || isError}
|
||||||
|
onClick={() => approve.mutate({ id: bookingId })}
|
||||||
|
>
|
||||||
|
Approve & sign delivery
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
|||||||
</Text>
|
</Text>
|
||||||
{onboardingDocs.map((doc, i) => (
|
{onboardingDocs.map((doc, i) => (
|
||||||
<Group
|
<Group
|
||||||
key={`${doc.url}-${i}`}
|
key={`${doc.id}-${i}`}
|
||||||
gap={12}
|
gap={12}
|
||||||
align="center"
|
align="center"
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
|
|||||||
@@ -144,6 +144,18 @@ export default function NewContractPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profile changes pending review lock out new contract creation too.
|
||||||
|
if (!auth.isPending && auth.isUnderReview) {
|
||||||
|
return (
|
||||||
|
<GateNotice
|
||||||
|
title="Profile Changes Under Review"
|
||||||
|
body="Your recent profile changes are awaiting administrator approval. Creating contracts is paused until the review is complete."
|
||||||
|
actionLabel="Back to Contracts"
|
||||||
|
onAction={() => navigate("/contracts")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [pricingData, setPricingData] =
|
const [pricingData, setPricingData] =
|
||||||
useState<GenerateContractPriceResponse | null>(null);
|
useState<GenerateContractPriceResponse | null>(null);
|
||||||
// In edit mode the contract already exists, so seed its id — this makes
|
// In edit mode the contract already exists, so seed its id — this makes
|
||||||
@@ -191,14 +203,18 @@ export default function NewContractPage({
|
|||||||
contractId = contract.id;
|
contractId = contract.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pricing = await api.contracts.generatePrice.call({ id: contractId });
|
const pricing = await api.contracts.generatePrice.call({
|
||||||
|
id: contractId,
|
||||||
|
});
|
||||||
return { contractId, pricing, mode };
|
return { contractId, pricing, mode };
|
||||||
},
|
},
|
||||||
onSuccess: ({ contractId, pricing, mode }) => {
|
onSuccess: ({ contractId, pricing, mode }) => {
|
||||||
setPriceContractId(contractId);
|
setPriceContractId(contractId);
|
||||||
setPricingData(pricing);
|
setPricingData(pricing);
|
||||||
setPriceModalMode(mode);
|
setPriceModalMode(mode);
|
||||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.contracts.list.queryKey(),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,7 +230,9 @@ export default function NewContractPage({
|
|||||||
}
|
}
|
||||||
clearContractDraft();
|
clearContractDraft();
|
||||||
setPriceModalMode(null);
|
setPriceModalMode(null);
|
||||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.contracts.list.queryKey(),
|
||||||
|
});
|
||||||
navigate("/contracts");
|
navigate("/contracts");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -228,7 +246,9 @@ export default function NewContractPage({
|
|||||||
clearContractDraft();
|
clearContractDraft();
|
||||||
setPriceChangeResult(null);
|
setPriceChangeResult(null);
|
||||||
setPriceModalMode(null);
|
setPriceModalMode(null);
|
||||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.contracts.list.queryKey(),
|
||||||
|
});
|
||||||
navigate("/contracts");
|
navigate("/contracts");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -242,7 +262,9 @@ export default function NewContractPage({
|
|||||||
clearContractDraft();
|
clearContractDraft();
|
||||||
setPriceModalMode(null);
|
setPriceModalMode(null);
|
||||||
setPriceContractId(null);
|
setPriceContractId(null);
|
||||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.contracts.list.queryKey(),
|
||||||
|
});
|
||||||
navigate("/contracts");
|
navigate("/contracts");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -325,6 +347,17 @@ export default function NewContractPage({
|
|||||||
m.set(p.type, p.status);
|
m.set(p.type, p.status);
|
||||||
return m;
|
return m;
|
||||||
}, [auth.company]);
|
}, [auth.company]);
|
||||||
|
// Full profile per type, so the awaiting/rejected modal can show the reviewer
|
||||||
|
// note and offer a reapply for a rejected role.
|
||||||
|
const profileByType = useMemo(() => {
|
||||||
|
const m = new Map<
|
||||||
|
string,
|
||||||
|
{ id: string; status: string; reviewNote?: string | null }
|
||||||
|
>();
|
||||||
|
for (const p of auth.company?.company?.companyProfiles ?? [])
|
||||||
|
m.set(p.type, { id: p.id, status: p.status, reviewNote: p.reviewNote });
|
||||||
|
return m;
|
||||||
|
}, [auth.company]);
|
||||||
const profileTypes = useMemo(
|
const profileTypes = useMemo(
|
||||||
() => [...profileStatusByType.keys()],
|
() => [...profileStatusByType.keys()],
|
||||||
[profileStatusByType],
|
[profileStatusByType],
|
||||||
@@ -343,12 +376,14 @@ export default function NewContractPage({
|
|||||||
// badges. Intercity rides any customer profile, so always "approved".
|
// badges. Intercity rides any customer profile, so always "approved".
|
||||||
const operationStatus = useMemo(
|
const operationStatus = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(op: OperationType): "approved" | "pending" | "missing" => {
|
(op: OperationType): "approved" | "pending" | "rejected" | "missing" => {
|
||||||
if (op === "intercity") return "approved";
|
if (op === "intercity") return "approved";
|
||||||
const target = operationToProfileType(op, profileTypes);
|
const target = operationToProfileType(op, profileTypes);
|
||||||
const status = profileStatusByType.get(target);
|
const status = profileStatusByType.get(target);
|
||||||
if (!status) return "missing";
|
if (!status) return "missing";
|
||||||
return status === "active" ? "approved" : "pending";
|
if (status === "active") return "approved";
|
||||||
|
if (status === "rejected") return "rejected";
|
||||||
|
return "pending";
|
||||||
},
|
},
|
||||||
[profileStatusByType, profileTypes],
|
[profileStatusByType, profileTypes],
|
||||||
);
|
);
|
||||||
@@ -398,6 +433,17 @@ export default function NewContractPage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resubmit a rejected operational role for approval (from the block modal).
|
||||||
|
const reapplyMutation = useMutation({
|
||||||
|
mutationFn: async (profileId: string) => {
|
||||||
|
const res = await auth.reapplyProfile(profileId);
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(res.error?.message ?? "Failed to resubmit for approval");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => setPendingApprovalProfile(null),
|
||||||
|
});
|
||||||
|
|
||||||
const handleOperationSelect = (op: OperationType) => {
|
const handleOperationSelect = (op: OperationType) => {
|
||||||
// Intercity (domestic) runs on any existing customer profile — no switch.
|
// Intercity (domestic) runs on any existing customer profile — no switch.
|
||||||
if (op === "intercity") return;
|
if (op === "intercity") return;
|
||||||
@@ -551,23 +597,23 @@ export default function NewContractPage({
|
|||||||
: {}),
|
: {}),
|
||||||
...(serviceType?.includesFirstMile && data.firstMile.enabled
|
...(serviceType?.includesFirstMile && data.firstMile.enabled
|
||||||
? {
|
? {
|
||||||
firstMilePickupAddress: data.firstMile.pickUpAddress,
|
firstMilePickupAddress: data.firstMile.pickUpAddress,
|
||||||
firstMilePickupLat: data.firstMile.lat ?? undefined,
|
firstMilePickupLat: data.firstMile.lat ?? undefined,
|
||||||
firstMilePickupLng: data.firstMile.lng ?? undefined,
|
firstMilePickupLng: data.firstMile.lng ?? undefined,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(serviceType?.includesLastMile && data.lastMile.enabled
|
...(serviceType?.includesLastMile && data.lastMile.enabled
|
||||||
? {
|
? {
|
||||||
lastMileDeliveryAddress: data.lastMile.deliveryAddress,
|
lastMileDeliveryAddress: data.lastMile.deliveryAddress,
|
||||||
lastMileDeliveryLat: data.lastMile.lat ?? undefined,
|
lastMileDeliveryLat: data.lastMile.lat ?? undefined,
|
||||||
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
|
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(serviceType?.includesCustoms && data.customsClearingEnabled
|
...(serviceType?.includesCustoms && data.customsClearingEnabled
|
||||||
? {
|
? {
|
||||||
customsClearingEnabled: true,
|
customsClearingEnabled: true,
|
||||||
customsClearingAgent: data.customsClearingAgent || undefined,
|
customsClearingAgent: data.customsClearingAgent || undefined,
|
||||||
}
|
}
|
||||||
: { customsClearingEnabled: false }),
|
: { customsClearingEnabled: false }),
|
||||||
cargoScope,
|
cargoScope,
|
||||||
routes,
|
routes,
|
||||||
@@ -652,7 +698,12 @@ export default function NewContractPage({
|
|||||||
mb="lg"
|
mb="lg"
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
<Title
|
||||||
|
order={1}
|
||||||
|
fw={800}
|
||||||
|
fz={26}
|
||||||
|
style={{ letterSpacing: "-0.01em" }}
|
||||||
|
>
|
||||||
{isEdit ? "Edit Contract" : "New Contract"}
|
{isEdit ? "Edit Contract" : "New Contract"}
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="sm" c="edr-muted" mt={4}>
|
<Text size="sm" c="edr-muted" mt={4}>
|
||||||
@@ -791,12 +842,12 @@ export default function NewContractPage({
|
|||||||
errors={
|
errors={
|
||||||
showDocErrors
|
showDocErrors
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
missingRequiredDocKeys(
|
missingRequiredDocKeys(
|
||||||
editDocSettingQuery.data,
|
editDocSettingQuery.data,
|
||||||
editContract,
|
editContract,
|
||||||
editDocuments,
|
editDocuments,
|
||||||
).map((k) => [k, "Required"]),
|
).map((k) => [k, "Required"]),
|
||||||
)
|
)
|
||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -814,9 +865,9 @@ export default function NewContractPage({
|
|||||||
pricing={
|
pricing={
|
||||||
pricingData
|
pricingData
|
||||||
? {
|
? {
|
||||||
currency: pricingData.currency,
|
currency: pricingData.currency,
|
||||||
lineItems: pricingData.lineItems,
|
lineItems: pricingData.lineItems,
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onSaveDraft={handleSaveDraft}
|
onSaveDraft={handleSaveDraft}
|
||||||
@@ -926,8 +977,8 @@ export default function NewContractPage({
|
|||||||
Pricing schedule
|
Pricing schedule
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" mb="md">
|
<Text size="xs" c="dimmed" mb="md">
|
||||||
Final amount is calculated at booking — quantities are unknown at
|
Final amount is calculated at booking — quantities are unknown
|
||||||
the contract stage.
|
at the contract stage.
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={10}>
|
<Stack gap={10}>
|
||||||
{pricingData.lineItems.map((item) => (
|
{pricingData.lineItems.map((item) => (
|
||||||
@@ -1087,7 +1138,7 @@ export default function NewContractPage({
|
|||||||
loading={confirmSubmitMutation.isPending}
|
loading={confirmSubmitMutation.isPending}
|
||||||
onClick={() => confirmSubmitMutation.mutate()}
|
onClick={() => confirmSubmitMutation.mutate()}
|
||||||
>
|
>
|
||||||
Confirm & submit
|
Confirm {"&"} submit
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1131,9 +1182,9 @@ export default function NewContractPage({
|
|||||||
<Check size={18} />
|
<Check size={18} />
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
License submitted. Your {createTargetLabel.toLowerCase()} profile
|
License submitted. Your {createTargetLabel.toLowerCase()}{" "}
|
||||||
is now awaiting staff approval. We'll notify you once it's
|
profile is now awaiting staff approval. We'll notify you once
|
||||||
approved — then you can create this contract as{" "}
|
it's approved — then you can create this contract as{" "}
|
||||||
{createTargetLabel.toLowerCase()}.
|
{createTargetLabel.toLowerCase()}.
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -1146,9 +1197,9 @@ export default function NewContractPage({
|
|||||||
) : (
|
) : (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
|
You don't have a {createTargetLabel.toLowerCase()} profile yet.
|
||||||
your business license to create one. It goes to staff for approval
|
Add your business license to create one. It goes to staff for
|
||||||
before you can use it.
|
approval before you can use it.
|
||||||
</Text>
|
</Text>
|
||||||
<FileInput
|
<FileInput
|
||||||
label="Business license"
|
label="Business license"
|
||||||
@@ -1181,35 +1232,78 @@ export default function NewContractPage({
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Awaiting-approval modal — the chosen operation maps to a profile that
|
{/* Awaiting-approval / rejected modal — the chosen operation maps to a
|
||||||
exists but isn't approved yet. The select was already reverted. */}
|
profile that exists but isn't active. The select was already reverted. */}
|
||||||
<Modal
|
{(() => {
|
||||||
opened={pendingApprovalProfile !== null}
|
const target = pendingApprovalProfile
|
||||||
onClose={() => setPendingApprovalProfile(null)}
|
? profileByType.get(pendingApprovalProfile)
|
||||||
title="Awaiting approval"
|
: undefined;
|
||||||
centered
|
const isRejected = target?.status === "rejected";
|
||||||
radius="lg"
|
const label = pendingApprovalProfile
|
||||||
>
|
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
||||||
<Stack gap="md">
|
pendingApprovalProfile)
|
||||||
<Text size="sm" c="dimmed">
|
: "";
|
||||||
Your{" "}
|
return (
|
||||||
{pendingApprovalProfile
|
<Modal
|
||||||
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
opened={pendingApprovalProfile !== null}
|
||||||
pendingApprovalProfile)
|
onClose={() => setPendingApprovalProfile(null)}
|
||||||
: ""}{" "}
|
title={isRejected ? "Profile not approved" : "Awaiting approval"}
|
||||||
profile was submitted and is under staff review. You can start a
|
centered
|
||||||
contract under it once it's approved.
|
radius="lg"
|
||||||
</Text>
|
>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Stack gap="md">
|
||||||
<Button
|
{isRejected ? (
|
||||||
color="edr-green"
|
<>
|
||||||
onClick={() => setPendingApprovalProfile(null)}
|
<Text size="sm" c="dimmed">
|
||||||
>
|
Your {label} profile was not approved. Fix the issue below
|
||||||
OK
|
and resubmit it for review.
|
||||||
</Button>
|
</Text>
|
||||||
</Group>
|
{target?.reviewNote && (
|
||||||
</Stack>
|
<Alert color="red" variant="light" radius="md">
|
||||||
</Modal>
|
<Text size="sm">
|
||||||
|
<strong>Reviewer note:</strong> {target.reviewNote}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setPendingApprovalProfile(null)}
|
||||||
|
disabled={reapplyMutation.isPending}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={reapplyMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
target && reapplyMutation.mutate(target.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Resubmit for approval
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Your {label} profile was submitted and is under staff
|
||||||
|
review. You can start a contract under it once it's approved.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
onClick={() => setPendingApprovalProfile(null)}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1151,9 +1151,14 @@ function ContainerLineEditor({
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
{...field}
|
{...field}
|
||||||
|
<<<<<<< HEAD
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(e.currentTarget.value.toUpperCase())
|
field.onChange(e.currentTarget.value.toUpperCase())
|
||||||
}
|
}
|
||||||
|
=======
|
||||||
|
onChange={(e) => field.onChange(e.currentTarget.value.toUpperCase())}
|
||||||
|
maxLength={11}
|
||||||
|
>>>>>>> 1b9ac42ed3fc9bd6b8dea8d08b5c4c066d083feb
|
||||||
label={u === 0 ? "Container number *" : undefined}
|
label={u === 0 ? "Container number *" : undefined}
|
||||||
placeholder="e.g. MSCU1234567"
|
placeholder="e.g. MSCU1234567"
|
||||||
error={fieldState.error?.message}
|
error={fieldState.error?.message}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function StepDocuments({
|
|||||||
</Text>
|
</Text>
|
||||||
{onboardingDocs.map((doc, i) => (
|
{onboardingDocs.map((doc, i) => (
|
||||||
<Group
|
<Group
|
||||||
key={`${doc.url}-${i}`}
|
key={`${doc.id}-${i}`}
|
||||||
gap={12}
|
gap={12}
|
||||||
align="center"
|
align="center"
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export function Step1ContractType({
|
|||||||
allowedOperations: OperationType[];
|
allowedOperations: OperationType[];
|
||||||
onOperationSelect?: (op: OperationType) => void;
|
onOperationSelect?: (op: OperationType) => void;
|
||||||
/** Approval state of the profile each operation maps to (for the badges). */
|
/** Approval state of the profile each operation maps to (for the badges). */
|
||||||
operationStatus?: (op: OperationType) => "approved" | "pending" | "missing";
|
operationStatus?: (
|
||||||
|
op: OperationType,
|
||||||
|
) => "approved" | "pending" | "rejected" | "missing";
|
||||||
}) {
|
}) {
|
||||||
const contractType = form.watch("contractType");
|
const contractType = form.watch("contractType");
|
||||||
|
|
||||||
@@ -221,6 +223,11 @@ export function Step1ContractType({
|
|||||||
Pending
|
Pending
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{status === "rejected" && (
|
||||||
|
<Badge size="xs" color="red" variant="light" radius="sm">
|
||||||
|
Rejected
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{status === "missing" && (
|
{status === "missing" && (
|
||||||
<Badge size="xs" color="gray" variant="light" radius="sm">
|
<Badge size="xs" color="gray" variant="light" radius="sm">
|
||||||
Add license
|
Add license
|
||||||
|
|||||||
@@ -26,16 +26,31 @@ export interface ShipmentValidationContext {
|
|||||||
requiresDate?: boolean;
|
requiresDate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
|
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
|
||||||
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
|
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
|
||||||
|
=======
|
||||||
|
// ISO 6346: 4 letters (owner + category) + 6 serial digits + 1 check digit.
|
||||||
|
// Enforced at booking input so the number stays a clean reference in the warehouse.
|
||||||
|
const ISO_CONTAINER_RE = /^[A-Z]{4}\d{7}$/;
|
||||||
|
>>>>>>> 1b9ac42ed3fc9bd6b8dea8d08b5c4c066d083feb
|
||||||
|
|
||||||
const containerUnitSchema = z.object({
|
const containerUnitSchema = z.object({
|
||||||
containerNumber: z
|
containerNumber: z
|
||||||
.string()
|
.string()
|
||||||
|
<<<<<<< HEAD
|
||||||
.min(1, "Container number is required.")
|
.min(1, "Container number is required.")
|
||||||
.refine(
|
.refine(
|
||||||
(v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()),
|
(v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()),
|
||||||
"Enter a valid ISO container number (e.g. ABCD1234567).",
|
"Enter a valid ISO container number (e.g. ABCD1234567).",
|
||||||
|
=======
|
||||||
|
.transform((v) => v.trim().toUpperCase())
|
||||||
|
.pipe(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.min(1, "Container number is required.")
|
||||||
|
.regex(ISO_CONTAINER_RE, "Use ISO format: 4 letters + 7 digits, e.g. ABCU1234567."),
|
||||||
|
>>>>>>> 1b9ac42ed3fc9bd6b8dea8d08b5c4c066d083feb
|
||||||
),
|
),
|
||||||
sealNumber: z.string().default(""),
|
sealNumber: z.string().default(""),
|
||||||
vgmTons: z
|
vgmTons: z
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user