mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
Merge pull request #445 from Tria-plc/freight/feature/first_mile_invoice
Freight/feature/first mile invoice
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow more than one vehicle per first-mile pickup. Junction table joins
|
||||||
|
* first_mile ⇄ vehicles, with each truck's container number + actual distance;
|
||||||
|
* existing single vehicle_id values are backfilled as the first assignment so
|
||||||
|
* nothing is lost. Mirrors the last-mile vehicle-assignment schema.
|
||||||
|
*/
|
||||||
|
export class AddFirstMileVehicleAssignments1940000000000 implements MigrationInterface {
|
||||||
|
name = "AddFirstMileVehicleAssignments1940000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.first_mile_vehicle_assignments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
first_mile_id uuid NOT NULL REFERENCES freight.first_mile(id) ON DELETE CASCADE,
|
||||||
|
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id),
|
||||||
|
container_number varchar,
|
||||||
|
distance_km numeric(10,2),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT "UQ_FIRST_MILE_VEHICLE" UNIQUE (first_mile_id, vehicle_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_FM_VEHICLE_ASSIGNMENTS_VEHICLE"
|
||||||
|
ON freight.first_mile_vehicle_assignments (vehicle_id)
|
||||||
|
`);
|
||||||
|
// Backfill: existing single-vehicle assignments become the first row
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.first_mile_vehicle_assignments (first_mile_id, vehicle_id)
|
||||||
|
SELECT id, vehicle_id FROM freight.first_mile
|
||||||
|
WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL
|
||||||
|
ON CONFLICT (first_mile_id, vehicle_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.first_mile_vehicle_assignments`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class VehicleDistanceInput {
|
||||||
|
@IsUUID()
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
distanceKm!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-vehicle actual distances for a first-mile pickup (multi-truck). */
|
||||||
|
export class SetDistancesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => VehicleDistanceInput)
|
||||||
|
distances!: VehicleDistanceInput[];
|
||||||
|
|
||||||
|
/** Recomputed remaining payment (total km × rate), from the client. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
remainingPayment?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class FirstMileVehicleInput {
|
||||||
|
@IsUUID()
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
containerNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the full set of vehicles (with their container numbers) on a pickup. */
|
||||||
|
export class SetVehiclesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => FirstMileVehicleInput)
|
||||||
|
vehicles!: FirstMileVehicleInput[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
|
||||||
|
|
||||||
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
|
import { FirstMile } from './first-mile.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per vehicle assigned to a first-mile pickup. A pickup can be served
|
||||||
|
* by several vehicles at once (multi-truck bookings); the legacy
|
||||||
|
* `first_mile.vehicle_id` column keeps pointing at the first assignment for
|
||||||
|
* backward compatibility.
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'first_mile_vehicle_assignments', schema: 'freight' })
|
||||||
|
@Unique(['firstMileId', 'vehicleId'])
|
||||||
|
@Index(['vehicleId'])
|
||||||
|
export class FirstMileVehicleAssignment extends BaseEntity {
|
||||||
|
@Column({ name: 'first_mile_id', type: 'uuid' })
|
||||||
|
firstMileId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => FirstMile, (fm) => fm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'first_mile_id' })
|
||||||
|
firstMile?: FirstMile;
|
||||||
|
|
||||||
|
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Vehicle, { nullable: false, eager: false })
|
||||||
|
@JoinColumn({ name: 'vehicle_id' })
|
||||||
|
vehicle?: Vehicle;
|
||||||
|
|
||||||
|
/** Container this truck carries — auto-filled from the booking's container
|
||||||
|
* number when known, else entered manually at assignment time. */
|
||||||
|
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
||||||
|
containerNumber?: string | null;
|
||||||
|
|
||||||
|
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||||
|
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
|
distanceKm?: number | null;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
|
|||||||
import { Booking } from '../../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
|
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
|
||||||
|
import { FirstMileVehicleAssignment } from './first-mile-vehicle-assignment.entity';
|
||||||
|
|
||||||
export const FIRST_MILE_STATUSES = [
|
export const FIRST_MILE_STATUSES = [
|
||||||
'PAYMENT_PENDING',
|
'PAYMENT_PENDING',
|
||||||
@@ -61,4 +62,7 @@ export class FirstMile extends BaseEntity {
|
|||||||
{ eager: false },
|
{ eager: false },
|
||||||
)
|
)
|
||||||
containerAllocations!: FirstMileContainerAllocation[];
|
containerAllocations!: FirstMileContainerAllocation[];
|
||||||
|
|
||||||
|
@OneToMany(() => FirstMileVehicleAssignment, (va) => va.firstMile)
|
||||||
|
vehicleAssignments?: FirstMileVehicleAssignment[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
|
|||||||
|
|
||||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||||
|
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||||
|
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||||
import { FirstMileStatus } from './entities/first-mile.entity';
|
import { FirstMileStatus } from './entities/first-mile.entity';
|
||||||
import { FirstMileService } from './first-mile.service';
|
import { FirstMileService } from './first-mile.service';
|
||||||
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
||||||
@@ -103,6 +105,26 @@ export class FirstMileController {
|
|||||||
return invoice;
|
return invoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/vehicles')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' })
|
||||||
|
async setVehicles(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SetVehiclesDto,
|
||||||
|
) {
|
||||||
|
return this.firstMileService.setVehicles(id, dto.vehicles);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/distances')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
|
||||||
|
async setDistances(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SetDistancesDto,
|
||||||
|
) {
|
||||||
|
return this.firstMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
|||||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||||
import { FirstMile } from './entities/first-mile.entity';
|
import { FirstMile } from './entities/first-mile.entity';
|
||||||
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
||||||
|
import { FirstMileVehicleAssignment } from './entities/first-mile-vehicle-assignment.entity';
|
||||||
import { FirstMileController } from './first-mile.controller';
|
import { FirstMileController } from './first-mile.controller';
|
||||||
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
||||||
import { FirstMileRepository } from './first-mile.repository';
|
import { FirstMileRepository } from './first-mile.repository';
|
||||||
@@ -15,7 +16,7 @@ import { FirstMileService } from './first-mile.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
|
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation, FirstMileVehicleAssignment]),
|
||||||
forwardRef(() => BillingModule),
|
forwardRef(() => BillingModule),
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere, IsNull, Not } from 'typeorm';
|
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||||
@@ -11,6 +11,7 @@ import { CreateFirstMileDto } from "./dto/create-first-mile.dto";
|
|||||||
import { UpdateFirstMileDto } from "./dto/update-first-mile.dto";
|
import { UpdateFirstMileDto } from "./dto/update-first-mile.dto";
|
||||||
import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity";
|
import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity";
|
||||||
import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity";
|
import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity";
|
||||||
|
import { FirstMileVehicleAssignment } from "./entities/first-mile-vehicle-assignment.entity";
|
||||||
import { FirstMileRepository } from "./first-mile.repository";
|
import { FirstMileRepository } from "./first-mile.repository";
|
||||||
import { OnEvent } from "@nestjs/event-emitter";
|
import { OnEvent } from "@nestjs/event-emitter";
|
||||||
import { BillingService, InvoiceEventPayload } from "../billing/billing.service";
|
import { BillingService, InvoiceEventPayload } from "../billing/billing.service";
|
||||||
@@ -92,10 +93,15 @@ export class FirstMileService {
|
|||||||
directVehicleId?: string | null,
|
directVehicleId?: string | null,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (directVehicleId) return true;
|
if (directVehicleId) return true;
|
||||||
const count = await this.dataSource.manager.count(FirstMileContainerAllocation, {
|
const [junction, allocations] = await Promise.all([
|
||||||
where: { firstMileId: recordId, vehicleId: Not(IsNull()) },
|
this.dataSource.manager.count(FirstMileVehicleAssignment, {
|
||||||
});
|
where: { firstMileId: recordId },
|
||||||
return count > 0;
|
}),
|
||||||
|
this.dataSource.manager.count(FirstMileContainerAllocation, {
|
||||||
|
where: { firstMileId: recordId, vehicleId: Not(IsNull()) },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return junction > 0 || allocations > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human booking reference for a first-mile record, for the history timeline. */
|
/** Human booking reference for a first-mile record, for the history timeline. */
|
||||||
@@ -204,6 +210,7 @@ export class FirstMileService {
|
|||||||
cargoType: true,
|
cargoType: true,
|
||||||
},
|
},
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
|
vehicleAssignments: { vehicle: true },
|
||||||
},
|
},
|
||||||
order: { [sortBy]: sortOrder },
|
order: { [sortBy]: sortOrder },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
@@ -250,6 +257,7 @@ export class FirstMileService {
|
|||||||
cargoType: true,
|
cargoType: true,
|
||||||
},
|
},
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
|
vehicleAssignments: { vehicle: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -490,18 +498,154 @@ export class FirstMileService {
|
|||||||
* allocations), unless still in use by another active trip.
|
* allocations), unless still in use by another active trip.
|
||||||
*/
|
*/
|
||||||
private async releaseVehicles(record: FirstMile): Promise<void> {
|
private async releaseVehicles(record: FirstMile): Promise<void> {
|
||||||
const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, {
|
const [assignments, recordAllocations] = await Promise.all([
|
||||||
where: { firstMileId: record.id },
|
this.dataSource.manager.find(FirstMileVehicleAssignment, {
|
||||||
});
|
where: { firstMileId: record.id },
|
||||||
const vehicleIds = recordAllocations
|
}),
|
||||||
.map((a) => a.vehicleId)
|
this.dataSource.manager.find(FirstMileContainerAllocation, {
|
||||||
.filter((id): id is string => Boolean(id));
|
where: { firstMileId: record.id },
|
||||||
if (record.vehicleId) {
|
}),
|
||||||
vehicleIds.push(record.vehicleId);
|
]);
|
||||||
}
|
const vehicleIds = [
|
||||||
|
...new Set(
|
||||||
|
[
|
||||||
|
...assignments.map((a) => a.vehicleId),
|
||||||
|
...recordAllocations.map((a) => a.vehicleId),
|
||||||
|
record.vehicleId ?? null,
|
||||||
|
].filter((id): id is string => Boolean(id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the full set of vehicles serving a first-mile pickup (multi-truck).
|
||||||
|
* Diffs against the current junction rows, syncing availability + audit history
|
||||||
|
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
||||||
|
* `vehicleId` column for back-compat with single-vehicle readers.
|
||||||
|
*/
|
||||||
|
async setVehicles(
|
||||||
|
id: string,
|
||||||
|
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||||
|
): Promise<FirstMile> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
// Dedupe by vehicleId, keeping the container number; preserve order.
|
||||||
|
const desiredMap = new Map<string, string | null>();
|
||||||
|
for (const inp of inputs) {
|
||||||
|
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
||||||
|
}
|
||||||
|
const desired = [...desiredMap.keys()];
|
||||||
|
const desiredSet = new Set(desired);
|
||||||
|
|
||||||
|
const manager = this.dataSource.manager;
|
||||||
|
const current = await manager.find(FirstMileVehicleAssignment, {
|
||||||
|
where: { firstMileId: id },
|
||||||
|
});
|
||||||
|
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||||
|
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||||
|
// old single-vehicle path has no junction row but must still be freed.
|
||||||
|
const releaseIds = [...new Set(
|
||||||
|
current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []),
|
||||||
|
)];
|
||||||
|
const added = desired.filter((v) => !junctionSet.has(v));
|
||||||
|
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
||||||
|
// Vehicles that stay but whose container number changed.
|
||||||
|
const changed = current.filter(
|
||||||
|
(a) =>
|
||||||
|
desiredMap.has(a.vehicleId) &&
|
||||||
|
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (tx) => {
|
||||||
|
if (removed.length) {
|
||||||
|
await tx.delete(FirstMileVehicleAssignment, {
|
||||||
|
firstMileId: id,
|
||||||
|
vehicleId: In(removed),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const vehicleId of added) {
|
||||||
|
await tx.insert(FirstMileVehicleAssignment, {
|
||||||
|
firstMileId: id,
|
||||||
|
vehicleId,
|
||||||
|
containerNumber: desiredMap.get(vehicleId) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of changed) {
|
||||||
|
await tx.update(
|
||||||
|
FirstMileVehicleAssignment,
|
||||||
|
{ firstMileId: id, vehicleId: row.vehicleId },
|
||||||
|
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legacy primary vehicle = first of the set (null when cleared).
|
||||||
|
await this.firstMileRepository.update(id, { vehicleId: desired[0] ?? null } as any);
|
||||||
|
|
||||||
|
const bookingRef = await this.resolveBookingRef(existing);
|
||||||
|
for (const vehicleId of added) {
|
||||||
|
await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY);
|
||||||
|
void this.notifyDriverAssignment(vehicleId, existing);
|
||||||
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
||||||
|
vehicleId,
|
||||||
|
firstMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
label: existing.status,
|
||||||
|
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const vehicleId of removed) {
|
||||||
|
await this.vehiclesService.releaseIfUnused([vehicleId]);
|
||||||
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
||||||
|
vehicleId,
|
||||||
|
firstMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record each truck's actual distance. The pickup total (exact_km) is their
|
||||||
|
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
||||||
|
* client-side. Does NOT generate an invoice — that's a separate explicit step.
|
||||||
|
*/
|
||||||
|
async setDistances(
|
||||||
|
id: string,
|
||||||
|
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||||
|
remainingPayment?: number,
|
||||||
|
): Promise<FirstMile> {
|
||||||
|
await this.findById(id);
|
||||||
|
|
||||||
|
// Distances are locked once the invoice exists.
|
||||||
|
const invoices = await this.billing.findBySourceIds('first_mile', [id]);
|
||||||
|
if (invoices.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Distances cannot be changed after the invoice is generated',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of distances) {
|
||||||
|
await this.dataSource.manager.update(
|
||||||
|
FirstMileVehicleAssignment,
|
||||||
|
{ firstMileId: id, vehicleId: d.vehicleId },
|
||||||
|
{ distanceKm: d.distanceKm },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
|
||||||
|
await this.firstMileRepository.update(id, {
|
||||||
|
exactKm: total,
|
||||||
|
...(remainingPayment != null ? { remainingPayment } : {}),
|
||||||
|
} as any);
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||||
@@ -570,8 +714,44 @@ export class FirstMileService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every vehicle this pickup holds — junction + legacy + container rows.
|
||||||
|
const [assignments, allocations] = await Promise.all([
|
||||||
|
this.dataSource.manager.find(FirstMileVehicleAssignment, {
|
||||||
|
where: { firstMileId: id },
|
||||||
|
}),
|
||||||
|
this.dataSource.manager.find(FirstMileContainerAllocation, {
|
||||||
|
where: { firstMileId: id },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const vehicleIds = [
|
||||||
|
...new Set(
|
||||||
|
[
|
||||||
|
...assignments.map((a) => a.vehicleId),
|
||||||
|
...allocations.map((a) => a.vehicleId),
|
||||||
|
existing.vehicleId ?? null,
|
||||||
|
].filter((v): v is string => Boolean(v)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
await this.firstMileRepository.softDelete(id);
|
await this.firstMileRepository.softDelete(id);
|
||||||
// Free the trucks it was holding (direct + container), unless still in use.
|
if (assignments.length) {
|
||||||
await this.releaseVehicles(existing);
|
await this.dataSource.manager.softDelete(FirstMileVehicleAssignment, { firstMileId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free every vehicle no longer held by another active trip and audit release.
|
||||||
|
if (vehicleIds.length) {
|
||||||
|
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
||||||
|
const bookingRef = await this.resolveBookingRef(existing);
|
||||||
|
for (const vehicleId of vehicleIds) {
|
||||||
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
||||||
|
vehicleId,
|
||||||
|
firstMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
Plus,
|
||||||
Printer,
|
Printer,
|
||||||
Receipt,
|
Receipt,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Ruler,
|
Ruler,
|
||||||
Trash,
|
Trash,
|
||||||
Truck,
|
Truck,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -45,6 +47,7 @@ import {
|
|||||||
FIRST_MILE_STATUSES,
|
FIRST_MILE_STATUSES,
|
||||||
type FirstMileApiStatus,
|
type FirstMileApiStatus,
|
||||||
type FirstMileRecord,
|
type FirstMileRecord,
|
||||||
|
type FirstMileVehicle,
|
||||||
firstMileService,
|
firstMileService,
|
||||||
} from "@/services/first-mile.service";
|
} from "@/services/first-mile.service";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
@@ -109,7 +112,14 @@ const vehicleLabel = (record: FirstMileRecord) => {
|
|||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId);
|
const isAssigned = (record: FirstMileRecord) =>
|
||||||
|
Boolean(record.vehicleId) || Boolean(record.vehicleAssignments?.length);
|
||||||
|
|
||||||
|
/** Container numbers on a booking, in line order (skips lines without one). */
|
||||||
|
const bookingContainerNumbers = (record: FirstMileRecord): string[] =>
|
||||||
|
(record.booking?.bookingContainers ?? [])
|
||||||
|
.map((c) => c.containerNumber)
|
||||||
|
.filter((n): n is string => Boolean(n));
|
||||||
|
|
||||||
// Paid = record flag set OR its invoice reached PAID.
|
// Paid = record flag set OR its invoice reached PAID.
|
||||||
const isPaidRecord = (r: FirstMileRecord) =>
|
const isPaidRecord = (r: FirstMileRecord) =>
|
||||||
@@ -360,7 +370,10 @@ const FirstMilePage = () => {
|
|||||||
const [tripSlipOpen, setTripSlipOpen] = useState(false);
|
const [tripSlipOpen, setTripSlipOpen] = useState(false);
|
||||||
const [tripSlipRecord, setTripSlipRecord] = useState<FirstMileRecord | null>(null);
|
const [tripSlipRecord, setTripSlipRecord] = useState<FirstMileRecord | null>(null);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [vehicleValue, setVehicleValue] = useState<string | null>(null);
|
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
||||||
|
const [vehicleRows, setVehicleRows] = useState<
|
||||||
|
Array<{ vehicleId: string | null; containerNumber: string }>
|
||||||
|
>([{ vehicleId: null, containerNumber: "" }]);
|
||||||
|
|
||||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||||
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
|
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
|
||||||
@@ -369,7 +382,8 @@ const FirstMilePage = () => {
|
|||||||
const [bookingSearch, setBookingSearch] = useState("");
|
const [bookingSearch, setBookingSearch] = useState("");
|
||||||
|
|
||||||
const [distanceOpen, setDistanceOpen] = useState(false);
|
const [distanceOpen, setDistanceOpen] = useState(false);
|
||||||
const [distanceValue, setDistanceValue] = useState("");
|
// Per-vehicle actual distance, keyed by vehicleId.
|
||||||
|
const [distanceRows, setDistanceRows] = useState<Record<string, string>>({});
|
||||||
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
|
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
|
||||||
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
|
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
|
||||||
|
|
||||||
@@ -435,14 +449,36 @@ const FirstMilePage = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateDistanceMutation = useMutation({
|
const setVehiclesMutation = useMutation({
|
||||||
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
|
mutationFn: ({
|
||||||
firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
|
id,
|
||||||
|
vehicles,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>;
|
||||||
|
}) => firstMileService.setVehicles(id, vehicles),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||||
if (activeRecord) {
|
void qc.invalidateQueries({ queryKey: ["vehicles"] });
|
||||||
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` });
|
},
|
||||||
}
|
onError: () => {
|
||||||
|
toast({ title: "Assign failed", variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setDistancesMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
distances,
|
||||||
|
remainingPayment,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
distances: Array<{ vehicleId: string; distanceKm: number }>;
|
||||||
|
remainingPayment?: number;
|
||||||
|
}) => firstMileService.setDistances(id, distances, remainingPayment),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||||
|
toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined });
|
||||||
closeDistance();
|
closeDistance();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -501,6 +537,37 @@ const FirstMilePage = () => {
|
|||||||
[records, activeId],
|
[records, activeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Picker options = free vehicles PLUS the ones already on this record (which are
|
||||||
|
// BUSY, so absent from the free list) so a reassign shows its current trucks
|
||||||
|
// selected instead of blank.
|
||||||
|
const assignVehicleOptions = useMemo(() => {
|
||||||
|
const opts = [...vehicleOptions];
|
||||||
|
const seen = new Set(opts.map((o) => o.value));
|
||||||
|
const pushVehicle = (v?: FirstMileVehicle | null) => {
|
||||||
|
if (v && !seen.has(v.id)) {
|
||||||
|
seen.add(v.id);
|
||||||
|
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
|
||||||
|
if (v.code) parts.unshift(v.code);
|
||||||
|
opts.push({ value: v.id, label: parts.join(" · ") });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const a of activeRecord?.vehicleAssignments ?? []) pushVehicle(a.vehicle);
|
||||||
|
pushVehicle(activeRecord?.vehicle);
|
||||||
|
for (const a of activeRecord?.vehicleAssignments ?? []) {
|
||||||
|
if (!seen.has(a.vehicleId)) {
|
||||||
|
seen.add(a.vehicleId);
|
||||||
|
opts.push({
|
||||||
|
value: a.vehicleId,
|
||||||
|
label: a.containerNumber ? `Assigned · ${a.containerNumber}` : "Assigned vehicle",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activeRecord?.vehicleId && !seen.has(activeRecord.vehicleId)) {
|
||||||
|
opts.push({ value: activeRecord.vehicleId, label: "Assigned vehicle" });
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}, [vehicleOptions, activeRecord]);
|
||||||
|
|
||||||
const selectedIds = useMemo(
|
const selectedIds = useMemo(
|
||||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||||
[rowSelection],
|
[rowSelection],
|
||||||
@@ -554,15 +621,20 @@ const FirstMilePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openDistance = (id: string) => {
|
const openDistance = (id: string) => {
|
||||||
|
const rec = records.find((r) => r.id === id);
|
||||||
|
const rows: Record<string, string> = {};
|
||||||
|
for (const a of rec?.vehicleAssignments ?? []) {
|
||||||
|
rows[a.vehicleId] = a.distanceKm != null ? String(a.distanceKm) : "";
|
||||||
|
}
|
||||||
setActiveId(id);
|
setActiveId(id);
|
||||||
setDistanceValue("");
|
setDistanceRows(rows);
|
||||||
setDistanceOpen(true);
|
setDistanceOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDistance = () => {
|
const closeDistance = () => {
|
||||||
setDistanceOpen(false);
|
setDistanceOpen(false);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setDistanceValue("");
|
setDistanceRows({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -577,24 +649,27 @@ const FirstMilePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDistance = () => {
|
const handleSaveDistance = () => {
|
||||||
const distance = parseFloat(distanceValue);
|
const distances = Object.entries(distanceRows)
|
||||||
if (!activeId || isNaN(distance) || distance < 0) {
|
.map(([vehicleId, val]) => ({ vehicleId, distanceKm: parseFloat(val) }))
|
||||||
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
|
.filter((d) => !Number.isNaN(d.distanceKm) && d.distanceKm >= 0);
|
||||||
|
|
||||||
|
if (!activeId || !distances.length) {
|
||||||
|
toast({ title: "Invalid distance", description: "Enter a distance for at least one vehicle.", variant: "destructive" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const total = distances.reduce((s, d) => s + d.distanceKm, 0);
|
||||||
let remainingPayment: number | undefined;
|
let remainingPayment: number | undefined;
|
||||||
if (ratesData?.data) {
|
if (ratesData?.data) {
|
||||||
const firstMileRate = ratesData.data.find(
|
const firstMileRate = ratesData.data.find(
|
||||||
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
|
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
|
||||||
);
|
);
|
||||||
if (firstMileRate) {
|
if (firstMileRate) {
|
||||||
const rateValue = parseFloat(firstMileRate.rateValue);
|
remainingPayment = total * parseFloat(firstMileRate.rateValue);
|
||||||
remainingPayment = distance * rateValue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
|
setDistancesMutation.mutate({ id: activeId, distances, remainingPayment });
|
||||||
};
|
};
|
||||||
|
|
||||||
const matchesFilter = (r: FirstMileRecord) => {
|
const matchesFilter = (r: FirstMileRecord) => {
|
||||||
@@ -651,16 +726,29 @@ const FirstMilePage = () => {
|
|||||||
|
|
||||||
const openAssign = (id: string | null) => {
|
const openAssign = (id: string | null) => {
|
||||||
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
||||||
|
const rec = records.find((r) => r.id === resolved);
|
||||||
|
// Prefill each row's container number from the booking's container numbers
|
||||||
|
// (by order) when the assignment doesn't already carry one.
|
||||||
|
const nums = rec ? bookingContainerNumbers(rec) : [];
|
||||||
|
const rows =
|
||||||
|
rec?.vehicleAssignments?.length
|
||||||
|
? rec.vehicleAssignments.map((a, i) => ({
|
||||||
|
vehicleId: a.vehicleId,
|
||||||
|
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
||||||
|
}))
|
||||||
|
: rec?.vehicleId
|
||||||
|
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
||||||
|
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveId(resolved);
|
setActiveId(resolved);
|
||||||
setVehicleValue(null);
|
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openBulkAssign = () => {
|
const openBulkAssign = () => {
|
||||||
setBulkMode(true);
|
setBulkMode(true);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleValue(null);
|
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -668,28 +756,31 @@ const FirstMilePage = () => {
|
|||||||
setAssignOpen(false);
|
setAssignOpen(false);
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleValue(null);
|
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssign = () => {
|
const handleAssign = () => {
|
||||||
if (!vehicleValue) {
|
const seen = new Set<string>();
|
||||||
toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" });
|
const vehicles = vehicleRows
|
||||||
return;
|
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
|
||||||
}
|
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
||||||
|
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
|
||||||
|
const count = vehicles.length;
|
||||||
const targetIds = bulkMode
|
const targetIds = bulkMode
|
||||||
? selectedIds
|
? selectedIds
|
||||||
: [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id));
|
: [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
if (!targetIds.length) return;
|
if (!targetIds.length) return;
|
||||||
|
|
||||||
const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue;
|
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
||||||
|
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
||||||
Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } })))
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: "Vehicle assigned",
|
title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned",
|
||||||
description: bulkMode ? `${targetIds.length} pickups → ${selectedLabel}` : selectedLabel,
|
description:
|
||||||
|
count === 0
|
||||||
|
? bulkMode ? `${targetIds.length} pickups` : undefined
|
||||||
|
: `${bulkMode ? `${targetIds.length} pickups · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`,
|
||||||
});
|
});
|
||||||
if (bulkMode) setRowSelection({});
|
if (bulkMode) setRowSelection({});
|
||||||
closeAssign();
|
closeAssign();
|
||||||
@@ -772,8 +863,39 @@ const FirstMilePage = () => {
|
|||||||
id: "vehicle",
|
id: "vehicle",
|
||||||
header: "Vehicle",
|
header: "Vehicle",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => {
|
||||||
vehicleLabel(row.original) ?? <Text c="dimmed">Unassigned</Text>,
|
const assigns = row.original.vehicleAssignments ?? [];
|
||||||
|
if (assigns.length > 1) {
|
||||||
|
const labelFor = (a: (typeof assigns)[number]) => {
|
||||||
|
const v = a.vehicle;
|
||||||
|
const l = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
|
||||||
|
return a.containerNumber ? `${l} · ${a.containerNumber}` : l;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
withArrow
|
||||||
|
multiline
|
||||||
|
label={
|
||||||
|
<div style={{ whiteSpace: "pre-line" }}>
|
||||||
|
{assigns.map(labelFor).join("\n")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Group gap={4} wrap="nowrap" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{assigns[0].vehicle
|
||||||
|
? [assigns[0].vehicle.code, assigns[0].vehicle.plateNumber].filter(Boolean).join(" · ")
|
||||||
|
: assigns[0].vehicleId}
|
||||||
|
</Text>
|
||||||
|
<Badge size="sm" variant="light" color="blue">
|
||||||
|
+{assigns.length - 1}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return vehicleLabel(row.original) ?? <Text c="dimmed">Unassigned</Text>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "exactKm",
|
id: "exactKm",
|
||||||
@@ -1036,7 +1158,7 @@ const FirstMilePage = () => {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{bulkMode ? (
|
{bulkMode ? (
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Assigning a vehicle to{" "}
|
Assigning vehicles to{" "}
|
||||||
<Text span fw={600}>{selectedIds.length}</Text>{" "}
|
<Text span fw={600}>{selectedIds.length}</Text>{" "}
|
||||||
selected {selectedIds.length === 1 ? "pickup" : "pickups"}.
|
selected {selectedIds.length === 1 ? "pickup" : "pickups"}.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1046,28 +1168,80 @@ const FirstMilePage = () => {
|
|||||||
<Text size="sm" c="dimmed">No unassigned pickups available.</Text>
|
<Text size="sm" c="dimmed">No unassigned pickups available.</Text>
|
||||||
)}
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
<Select
|
<Stack gap="xs">
|
||||||
label="Vehicle"
|
{vehicleRows.map((row, i) => (
|
||||||
placeholder={vehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
|
<Group key={i} gap="xs" wrap="nowrap" align="flex-end">
|
||||||
description={
|
<Select
|
||||||
vehicleOptions.length === 0
|
style={{ flex: 1.4 }}
|
||||||
? "No free vehicles available — all vehicles are busy, in maintenance, or retired. Free up a vehicle in Fleet › Vehicles first."
|
label={i === 0 ? "Vehicle" : undefined}
|
||||||
: undefined
|
placeholder={assignVehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
|
||||||
}
|
data={assignVehicleOptions.filter(
|
||||||
data={vehicleOptions}
|
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
||||||
value={vehicleValue}
|
)}
|
||||||
onChange={setVehicleValue}
|
value={row.vehicleId}
|
||||||
searchable
|
onChange={(v) =>
|
||||||
disabled={vehicleOptions.length === 0}
|
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
|
||||||
/>
|
}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
disabled={assignVehicleOptions.length === 0}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
label={i === 0 ? "Container no." : undefined}
|
||||||
|
placeholder="Container number"
|
||||||
|
value={row.containerNumber}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
setVehicleRows((prev) =>
|
||||||
|
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{vehicleRows.length > 1 && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
aria-label="Remove vehicle"
|
||||||
|
onClick={() => setVehicleRows((prev) => prev.filter((_, idx) => idx !== i))}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<Plus size={14} />}
|
||||||
|
onClick={() =>
|
||||||
|
setVehicleRows((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
vehicleId: null,
|
||||||
|
containerNumber:
|
||||||
|
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
assignVehicleOptions.length === 0 ||
|
||||||
|
vehicleRows.some((r) => !r.vehicleId) ||
|
||||||
|
vehicleRows.filter((r) => r.vehicleId).length >= assignVehicleOptions.length
|
||||||
|
}
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Add vehicle
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAssign}
|
onClick={handleAssign}
|
||||||
loading={updateMutation.isPending}
|
loading={setVehiclesMutation.isPending}
|
||||||
disabled={bulkMode ? selectedIds.length === 0 : !activeRecord}
|
disabled={bulkMode ? selectedIds.length === 0 : !activeRecord}
|
||||||
>
|
>
|
||||||
{!bulkMode && activeRecord && isAssigned(activeRecord) ? "Reassign" : "Assign"}
|
{!bulkMode && activeRecord && isAssigned(activeRecord) ? "Update vehicles" : "Assign"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1274,34 +1448,51 @@ const FirstMilePage = () => {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{activeRecord && (
|
{activeRecord && (
|
||||||
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
<Stack gap="sm">
|
<Group justify="space-between">
|
||||||
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
|
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
|
||||||
<Group justify="space-between">
|
<Text size="xs" c="dimmed">Est. {activeRecord.estimatedKm ?? "—"} km</Text>
|
||||||
<Text size="xs" c="dimmed">Customer</Text>
|
</Group>
|
||||||
<Text size="sm">{customerName(activeRecord)}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="xs" c="dimmed">Est. Distance (KM)</Text>
|
|
||||||
<Text size="sm">{activeRecord.estimatedKm ?? "—"}</Text>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
<NumberInput
|
{(activeRecord?.vehicleAssignments?.length ?? 0) === 0 ? (
|
||||||
label="Actual Distance (KM)"
|
<Text size="sm" c="dimmed">Assign a vehicle before entering distance.</Text>
|
||||||
placeholder="Enter distance"
|
) : (
|
||||||
value={distanceValue}
|
<Stack gap="sm">
|
||||||
onChange={(v) => setDistanceValue(String(v ?? ""))}
|
{activeRecord!.vehicleAssignments!.map((a) => {
|
||||||
min={0}
|
const v = a.vehicle;
|
||||||
step={0.1}
|
const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
|
||||||
decimalScale={2}
|
return (
|
||||||
/>
|
<NumberInput
|
||||||
|
key={a.id}
|
||||||
|
label={`${label}${a.containerNumber ? ` · ${a.containerNumber}` : ""}`}
|
||||||
|
placeholder="Distance (km)"
|
||||||
|
value={distanceRows[a.vehicleId] ?? ""}
|
||||||
|
onChange={(val) =>
|
||||||
|
setDistanceRows((prev) => ({ ...prev, [a.vehicleId]: String(val ?? "") }))
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
decimalScale={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">Total</Text>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{Object.values(distanceRows)
|
||||||
|
.reduce((s, val) => s + (parseFloat(val) || 0), 0)
|
||||||
|
.toFixed(2)}{" "}
|
||||||
|
km
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="default" onClick={closeDistance}>Cancel</Button>
|
<Button variant="default" onClick={closeDistance}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSaveDistance}
|
onClick={handleSaveDistance}
|
||||||
loading={updateDistanceMutation.isPending}
|
loading={setDistancesMutation.isPending}
|
||||||
disabled={!distanceValue}
|
disabled={Object.values(distanceRows).every((v) => !v)}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ export interface FirstMileBooking {
|
|||||||
originYard?: { id: string; label?: string } | null;
|
originYard?: { id: string; label?: string } | null;
|
||||||
destinationYard?: { id: string; label?: string } | null;
|
destinationYard?: { id: string; label?: string } | null;
|
||||||
cargoType?: { id: string; label?: string } | null;
|
cargoType?: { id: string; label?: string } | null;
|
||||||
|
/** Container lines — total container count drives how many trucks are needed. */
|
||||||
|
bookingContainers?: Array<{
|
||||||
|
id: string;
|
||||||
|
quantity: number;
|
||||||
|
containerNumber?: string | null;
|
||||||
|
containerSize?: string | null;
|
||||||
|
containerType?: { id: string; name?: string; label?: string; code?: string } | null;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FirstMileVehicle {
|
export interface FirstMileVehicle {
|
||||||
@@ -30,9 +38,12 @@ export interface FirstMileVehicle {
|
|||||||
plateNumber: string;
|
plateNumber: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
vehicleType?: string | null;
|
||||||
code?: string | null;
|
code?: string | null;
|
||||||
powerPlateNo?: string | null;
|
powerPlateNo?: string | null;
|
||||||
trailerPlateNo?: string | null;
|
trailerPlateNo?: string | null;
|
||||||
|
assignedDriverId?: string | null;
|
||||||
|
assignedDriverName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FirstMileRecord {
|
export interface FirstMileRecord {
|
||||||
@@ -46,6 +57,14 @@ export interface FirstMileRecord {
|
|||||||
vehicleId?: string | null;
|
vehicleId?: string | null;
|
||||||
booking?: FirstMileBooking | null;
|
booking?: FirstMileBooking | null;
|
||||||
vehicle?: FirstMileVehicle | null;
|
vehicle?: FirstMileVehicle | null;
|
||||||
|
/** Full set of vehicles serving this pickup (multi-truck). */
|
||||||
|
vehicleAssignments?: Array<{
|
||||||
|
id: string;
|
||||||
|
vehicleId: string;
|
||||||
|
containerNumber?: string | null;
|
||||||
|
distanceKm?: number | null;
|
||||||
|
vehicle?: FirstMileVehicle | null;
|
||||||
|
}>;
|
||||||
/** Present only when an invoice has actually been generated (not on distance). */
|
/** Present only when an invoice has actually been generated (not on distance). */
|
||||||
invoice?: { id: string; number: string; status: string } | null;
|
invoice?: { id: string; number: string; status: string } | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -69,6 +88,15 @@ export const firstMileService = {
|
|||||||
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
|
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
api.delete<void>(FM.BY_ID(id)),
|
api.delete<void>(FM.BY_ID(id)),
|
||||||
|
setVehicles: (
|
||||||
|
id: string,
|
||||||
|
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||||
|
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/vehicles`, { vehicles }),
|
||||||
|
setDistances: (
|
||||||
|
id: string,
|
||||||
|
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||||
|
remainingPayment?: number,
|
||||||
|
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/distances`, { distances, remainingPayment }),
|
||||||
generateInvoice: (id: string) =>
|
generateInvoice: (id: string) =>
|
||||||
api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`),
|
api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user