resolve merge conflict

This commit is contained in:
Marshal
2026-07-09 03:39:06 +00:00
174 changed files with 8297 additions and 3001 deletions

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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',
]);
}
}

View File

@@ -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`,
);
}
}
}
}

View File

@@ -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';
`);
}
}

View File

@@ -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;
`);
}
}

View File

@@ -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(

View File

@@ -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),

View File

@@ -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;
} }

View File

@@ -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 12 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}: 12 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());
}
} }

View File

@@ -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;
} }

View File

@@ -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" })

View File

@@ -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,
], ],

View File

@@ -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);
}
}
} }
/** /**

View File

@@ -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" },
});
}
}

View File

@@ -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;
}
}

View File

@@ -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;
} }
} }

View File

@@ -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;
} }
} }

View File

@@ -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;
}

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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;
}

View File

@@ -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;
} }

View File

@@ -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) =>

View File

@@ -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()

View File

@@ -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);
} }

View File

@@ -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);

View File

@@ -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 */
}
}
}

View File

@@ -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' });

View File

@@ -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 = [

View File

@@ -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);

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;
} }

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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'],

View File

@@ -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;
} }

View File

@@ -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}`,
);
} }
} }

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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(

View File

@@ -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],

View File

@@ -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);

View File

@@ -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',

View File

@@ -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

View File

@@ -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,

View File

@@ -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 };

View 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);
};

View File

@@ -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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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";

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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))}

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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)}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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',

View File

@@ -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);
};

View File

@@ -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: {

View File

@@ -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) =>

View File

@@ -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",

View File

@@ -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={{

View File

@@ -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>

View File

@@ -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,

View File

@@ -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),
}, },
{ {

View File

@@ -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',

View File

@@ -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>

View File

@@ -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",

View File

@@ -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);
},
}; };

View File

@@ -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) =>

View File

@@ -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;
} }

View File

@@ -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 {

View File

@@ -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,
}; };
} }

View File

@@ -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;

View File

@@ -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 &amp; 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

View File

@@ -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"

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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={

View File

@@ -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 (12)" 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>
</> </>

View File

@@ -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>
);
}

View File

@@ -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}
/>
</>
); );
} }

View File

@@ -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 &amp; sign delivery
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -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"

View File

@@ -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 &amp; 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>
); );
} }

View File

@@ -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}

View File

@@ -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"

View File

@@ -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

View File

@@ -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