Merge pull request #277 from Tria-plc/freight/feature/vehicle_2

Freight/feature/vehicle 2
This commit is contained in:
yaschalew10
2026-06-24 16:13:34 +03:00
committed by GitHub
18 changed files with 238 additions and 53 deletions

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddVehicleCodeAndPlates1810000000002 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const hasCode = await queryRunner.hasColumn('freight.vehicles', 'code');
if (!hasCode) {
await queryRunner.addColumn(
'freight.vehicles',
new TableColumn({ name: 'code', type: 'varchar', isNullable: true }),
);
}
const hasPower = await queryRunner.hasColumn('freight.vehicles', 'power_plate_no');
if (!hasPower) {
await queryRunner.addColumn(
'freight.vehicles',
new TableColumn({ name: 'power_plate_no', type: 'varchar', isNullable: true }),
);
}
const hasTrailer = await queryRunner.hasColumn('freight.vehicles', 'trailer_plate_no');
if (!hasTrailer) {
await queryRunner.addColumn(
'freight.vehicles',
new TableColumn({ name: 'trailer_plate_no', type: 'varchar', isNullable: true }),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('freight.vehicles', 'trailer_plate_no');
await queryRunner.dropColumn('freight.vehicles', 'power_plate_no');
await queryRunner.dropColumn('freight.vehicles', 'code');
}
}

View File

@@ -1,14 +1,23 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module'; import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { FirstMile } from './entities/first-mile.entity'; import { FirstMile } from './entities/first-mile.entity';
import { FirstMileController } from './first-mile.controller'; import { FirstMileController } from './first-mile.controller';
import { FirstMileRepository } from './first-mile.repository'; import { FirstMileRepository } from './first-mile.repository';
import { FirstMileService } from './first-mile.service'; import { FirstMileService } from './first-mile.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule], imports: [
TypeOrmModule.forFeature([FirstMile]),
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [FirstMileController], controllers: [FirstMileController],
providers: [FirstMileRepository, FirstMileService], providers: [FirstMileRepository, FirstMileService],
exports: [FirstMileRepository, FirstMileService], exports: [FirstMileRepository, FirstMileService],

View File

@@ -1,7 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm'; import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { VehiclesService } from '../vehicles/vehicles.service';
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 { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
@@ -26,9 +29,14 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [
@Injectable() @Injectable()
export class FirstMileService { export class FirstMileService {
private readonly logger = new Logger(FirstMileService.name);
constructor( constructor(
private readonly firstMileRepository: FirstMileRepository, private readonly firstMileRepository: FirstMileRepository,
private readonly bookingsRepository: BookingsRepository, private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
) {} ) {}
/** /**
@@ -37,7 +45,7 @@ export class FirstMileService {
* unknown or the booking has not reached PAID status. * unknown or the booking has not reached PAID status.
*/ */
async acceptBooking(bookingReference: string): Promise<FirstMile | null> { async acceptBooking(bookingReference: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference); const booking = await this.bookingsRepository.findById(bookingReference);
if (!booking) { if (!booking) {
return null; return null;
@@ -119,7 +127,7 @@ export class FirstMileService {
} }
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> { async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
await this.findById(id); const existing = await this.findById(id);
const updated = await this.firstMileRepository.update(id, { const updated = await this.firstMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -135,9 +143,45 @@ export class FirstMileService {
throw new NotFoundException(`First-mile record ${id} not found`); throw new NotFoundException(`First-mile record ${id} not found`);
} }
// Notify assigned driver on every explicit vehicle assignment or reassignment
if (dto.vehicleId) {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
return updated; return updated;
} }
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
if (!vehicle.assignedDriverId) {
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
return;
}
const driver = await this.driversService.findById(vehicle.assignedDriverId);
if (!driver.phoneNumber) {
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
return;
}
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.firstMilePickupAddress,
destinationYard: booking?.originYard?.label,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}
}
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
await this.findById(id); await this.findById(id);
await this.firstMileRepository.softDelete(id); await this.firstMileRepository.softDelete(id);

View File

@@ -1,14 +1,14 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { NotificationsService } from "./notifications.service"; import { NotificationsService } from "./notifications.service";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
import { HttpModule } from "@nestjs/axios";
@Module({ @Module({
imports: [HttpModule], imports: [ConfigModule],
controllers: [], controllers: [],
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService], providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService],
exports: [NotificationsService], exports: [NotificationsService],
}) })
export class NotificationsModule { } export class NotificationsModule {}

View File

@@ -27,9 +27,29 @@ export class NotificationsService {
if (!strategy) { if (!strategy) {
throw new NotFoundException(); throw new NotFoundException();
} }
const sent = await strategy.send(recipient, message) const sent = await strategy.send(recipient, message);
this.logger.log(`is sent - ${sent}`) this.logger.log(`is sent - ${sent}`);
} }
async notifyDriverVehicleAssignment(params: {
driverPhone: string;
driverName: string;
vehiclePlateNumber: string;
bookingReference: string;
pickupAddress?: string | null;
destinationYard?: string | null;
}): Promise<void> {
const { driverPhone, driverName, vehiclePlateNumber, bookingReference, pickupAddress, destinationYard } = params;
const message =
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
`Booking: ${bookingReference}. Vehicle: ${vehiclePlateNumber}. ` +
(pickupAddress ? `Pickup: ${pickupAddress}. ` : '') +
(destinationYard ? `Destination: ${destinationYard}.` : '');
try {
await this.directSend('sms', driverPhone, message);
} catch (err) {
this.logger.error(`Failed to notify driver ${driverName} (${driverPhone}): ${String(err)}`);
}
}
} }

View File

@@ -1,25 +1,36 @@
import { Injectable} from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { NotificationStrategy } from "./notification.strategy";
import { HttpService } from '@nestjs/axios';
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from 'rxjs'; import axios from "axios";
import { NotificationStrategy } from "./notification.strategy";
@Injectable() @Injectable()
export class SmsNotificationStrategy implements NotificationStrategy { export class SmsNotificationStrategy implements NotificationStrategy {
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { } constructor(private readonly configService: ConfigService) {}
async send(recipient: string, message: string) {
const url = this.configService.get("OZIKING_SMS_URL")
const body = {
to: recipient,
text: message
}
const response = await firstValueFrom(
this.httpService.post(
url,
body,
),
);
return response.status === 201; async send(recipient: string, message: string): Promise<boolean> {
} const url =
this.configService.get<string>("OZIKING_SMS_URL") ??
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms";
await axios.post(
url,
{
to: recipient,
sourceId: this.configService.get<string>("OZIKING_SOURCE_ID") ?? "EDR",
sourceName: this.configService.get<string>("OZIKING_SOURCE_NAME") ?? "EDR Freight",
appKey: this.configService.get<string>("OZIKING_APP_KEY") ?? "",
text: message,
callbackUrl: "",
},
{
headers: {
accept: "*/*",
"Content-Type": "application/json",
},
},
);
return true;
}
} }

View File

@@ -19,6 +19,7 @@ import { InternalPaymentController } from "./internal-payment.controller";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module"; import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
import { FirstMileModule } from "../first-mile/first-mile.module";
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentRefundEntity } from "./entities/payment-refund.entity"; import { PaymentRefundEntity } from "./entities/payment-refund.entity";
@@ -29,6 +30,7 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
HttpModule.register({ timeout: 10_000 }), HttpModule.register({ timeout: 10_000 }),
ConfigModule, ConfigModule,
DropdownSettingsModule, DropdownSettingsModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule), forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
RabbitMQModule.forRootAsync({ RabbitMQModule.forRootAsync({

View File

@@ -35,6 +35,7 @@ import {
} from "./payments.dto"; } from "./payments.dto";
import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service"; import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
import { FirstMileService } from "../first-mile/first-mile.service";
/** Setting code holding the global ordering window (months) for general contracts. */ /** Setting code holding the global ordering window (months) for general contracts. */
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period"; const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
@@ -60,6 +61,7 @@ export class PaymentService {
@Inject(forwardRef(() => BookingBatchService)) @Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService, private readonly bookingBatchService: BookingBatchService,
private readonly dropdownSettings: DropdownSettingsService, private readonly dropdownSettings: DropdownSettingsService,
private readonly firstMileService: FirstMileService,
) { } ) { }
/** Configured general-contract ordering window in months (defaults to 3). */ /** Configured general-contract ordering window in months (defaults to 3). */
@@ -342,6 +344,8 @@ export class PaymentService {
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
: { paymentStatus: "PAID", status: "PAID" }, : { paymentStatus: "PAID", status: "PAID" },
); );
await this.firstMileService.acceptBooking(input.bookingId);
}); });
if (isGeneralContract) { if (isGeneralContract) {

View File

@@ -37,4 +37,16 @@ export class CreateVehicleDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
assignedDriverName?: string; assignedDriverName?: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
powerPlateNo?: string;
@IsOptional()
@IsString()
trailerPlateNo?: string;
} }

View File

@@ -62,4 +62,13 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'assigned_driver_name', nullable: true }) @Column({ name: 'assigned_driver_name', nullable: true })
assignedDriverName?: string; assignedDriverName?: string;
@Column({ name: 'code', nullable: true })
code?: string;
@Column({ name: 'power_plate_no', nullable: true })
powerPlateNo?: string;
@Column({ name: 'trailer_plate_no', nullable: true })
trailerPlateNo?: string;
} }

View File

@@ -1,3 +1,3 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; // export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001'; export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -47,7 +47,10 @@ export const vehiclesConfig: FleetResourceConfig = {
], ],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"], searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
columns: [ columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code", size: 110 },
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 }, { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 },
{ id: "powerPlateNo", header: "Power Plate No", accessorKey: "powerPlateNo", format: "code", size: 140 },
{ id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", format: "code", size: 140 },
{ id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", format: "code", size: 140 }, { id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", format: "code", size: 140 },
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 140 }, { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 140 },
{ id: "model", header: "Model", accessorKey: "model", format: "code", size: 120 }, { id: "model", header: "Model", accessorKey: "model", format: "code", size: 120 },
@@ -59,7 +62,10 @@ export const vehiclesConfig: FleetResourceConfig = {
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
], ],
formFields: [ formFields: [
{ name: "code", label: "Code", type: "text" },
{ name: "plateNumber", label: "Plate Number", type: "text", required: true }, { name: "plateNumber", label: "Plate Number", type: "text", required: true },
{ name: "powerPlateNo", label: "Power Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text" },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS }, { name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true }, { name: "manufacturer", label: "Manufacturer", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: true }, { name: "model", label: "Model", type: "text", required: true },
@@ -70,7 +76,10 @@ export const vehiclesConfig: FleetResourceConfig = {
{ name: "description", label: "Description", type: "textarea" }, { name: "description", label: "Description", type: "textarea" },
], ],
emptyValues: { emptyValues: {
code: "",
plateNumber: "", plateNumber: "",
powerPlateNo: "",
trailerPlateNo: "",
vehicleType: "TRUCK", vehicleType: "TRUCK",
manufacturer: "", manufacturer: "",
model: "", model: "",

View File

@@ -75,7 +75,11 @@ const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
const vehicleLabel = (record: FirstMileRecord) => { const vehicleLabel = (record: FirstMileRecord) => {
if (!record.vehicle) return null; if (!record.vehicle) return null;
const v = record.vehicle; const v = record.vehicle;
return `${v.manufacturer} ${v.model} (${v.plateNumber})`; const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
if (v.code) parts.unshift(v.code);
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
if (plates) parts.push(plates);
return parts.join(" · ");
}; };
const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId); const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId);
@@ -91,8 +95,9 @@ const cargoDesc = (r: FirstMileRecord) => {
}; };
const priceAmount = (r: FirstMileRecord) => const priceAmount = (r: FirstMileRecord) =>
r.booking?.totalAmount ?? r.advancedPayment; r.booking?.totalAmount ?? r.advancedPayment;
// First-mile destination is the origin yard (pickup → origin yard)
const destinationYardName = (r: FirstMileRecord) => const destinationYardName = (r: FirstMileRecord) =>
r.booking?.destinationYard?.name ?? "—"; r.booking?.originYard?.label ?? "—";
const contactPersonName = (r: FirstMileRecord) => const contactPersonName = (r: FirstMileRecord) =>
r.booking?.company?.contactPersonName ?? "—"; r.booking?.company?.contactPersonName ?? "—";
const contactPhone = (r: FirstMileRecord) => const contactPhone = (r: FirstMileRecord) =>
@@ -102,7 +107,7 @@ const requestedDate = (r: FirstMileRecord) => {
return d ? new Date(d).toISOString().slice(0, 10) : "—"; return d ? new Date(d).toISOString().slice(0, 10) : "—";
}; };
const serviceTypeName = (r: FirstMileRecord) => const serviceTypeName = (r: FirstMileRecord) =>
r.booking?.serviceType?.name ?? "—"; r.booking?.serviceType?.label ?? "—";
const InfoRow = ({ label, value }: { label: string; value: string }) => ( const InfoRow = ({ label, value }: { label: string; value: string }) => (
<Stack gap={2}> <Stack gap={2}>
@@ -135,7 +140,7 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => (
<InfoRow label="Customer" value={customerName(record)} /> <InfoRow label="Customer" value={customerName(record)} />
<InfoRow label="Service type" value={serviceTypeName(record)} /> <InfoRow label="Service type" value={serviceTypeName(record)} />
<InfoRow label="Pickup location" value={pickupLocation(record)} /> <InfoRow label="Pickup location" value={pickupLocation(record)} />
<InfoRow label="Destination yard" value={destinationYardName(record)} /> <InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
<InfoRow label="Cargo" value={cargoDesc(record)} /> <InfoRow label="Cargo" value={cargoDesc(record)} />
<InfoRow label="Price" value={formatPrice(priceAmount(record))} /> <InfoRow label="Price" value={formatPrice(priceAmount(record))} />
<InfoRow label="Contact" value={contactPersonName(record)} /> <InfoRow label="Contact" value={contactPersonName(record)} />
@@ -345,10 +350,13 @@ const FirstMilePage = () => {
const vehicleOptions = useMemo( const vehicleOptions = useMemo(
() => () =>
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({ (Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
value: v.id, const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
label: `${v.manufacturer} ${v.model} (${v.plateNumber})`, if (v.code) parts.unshift(v.code);
})), const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
if (plates) parts.push(plates);
return { value: v.id, label: parts.join(" · ") };
}),
[vehiclesData], [vehiclesData],
); );
@@ -587,6 +595,12 @@ const FirstMilePage = () => {
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => pickupLocation(row.original), cell: ({ row }) => pickupLocation(row.original),
}, },
{
id: "destination",
header: "Destination",
meta: { headerClassName, cellClassName },
cell: ({ row }) => destinationYardName(row.original),
},
{ {
id: "cargo", id: "cargo",
header: "Cargo", header: "Cargo",
@@ -903,7 +917,7 @@ const FirstMilePage = () => {
</Stack> </Stack>
<Stack gap={3} align="flex-end" style={{ flexShrink: 0 }}> <Stack gap={3} align="flex-end" style={{ flexShrink: 0 }}>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{b.originYard?.name ?? "—"} {b.destinationYard?.name ?? "—"} {b.originYard?.label ?? "—"} {b.destinationYard?.label ?? "—"}
</Text> </Text>
<Text size="sm" fw={600} c="blue"> <Text size="sm" fw={600} c="blue">
{formatPrice(b.totalAmount)} {formatPrice(b.totalAmount)}
@@ -935,8 +949,8 @@ const FirstMilePage = () => {
<InfoRow label="Customer" value={selectedBooking.company?.name ?? selectedBooking.company?.companyName ?? "—"} /> <InfoRow label="Customer" value={selectedBooking.company?.name ?? selectedBooking.company?.companyName ?? "—"} />
<InfoRow label="Service type" value={selectedBooking.serviceType?.name ?? "—"} /> <InfoRow label="Service type" value={selectedBooking.serviceType?.name ?? "—"} />
<InfoRow label="Pickup address" value={selectedBooking.firstMilePickupAddress ?? "—"} /> <InfoRow label="Pickup address" value={selectedBooking.firstMilePickupAddress ?? "—"} />
<InfoRow label="Origin yard" value={selectedBooking.originYard?.name ?? "—"} /> <InfoRow label="Destination (origin yard)" value={selectedBooking.originYard?.label ?? "—"} />
<InfoRow label="Destination yard" value={selectedBooking.destinationYard?.name ?? "—"} /> <InfoRow label="Train destination yard" value={selectedBooking.destinationYard?.label ?? "—"} />
<InfoRow label="Cargo type" value={selectedBooking.cargoType?.name ?? "—"} /> <InfoRow label="Cargo type" value={selectedBooking.cargoType?.name ?? "—"} />
<InfoRow label="Weight (VGM)" value={`${selectedBooking.cargoTotalWeightVgm} t`} /> <InfoRow label="Weight (VGM)" value={`${selectedBooking.cargoTotalWeightVgm} t`} />
<InfoRow label="Total amount" value={formatPrice(selectedBooking.totalAmount)} /> <InfoRow label="Total amount" value={formatPrice(selectedBooking.totalAmount)} />

View File

@@ -71,7 +71,11 @@ const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
const vehicleLabel = (record: LastMileRecord) => { const vehicleLabel = (record: LastMileRecord) => {
if (!record.vehicle) return null; if (!record.vehicle) return null;
const v = record.vehicle; const v = record.vehicle;
return `${v.manufacturer} ${v.model} (${v.plateNumber})`; const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
if (v.code) parts.unshift(v.code);
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
if (plates) parts.push(plates);
return parts.join(" · ");
}; };
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
@@ -313,10 +317,13 @@ const LastMilePage = () => {
const vehicleOptions = useMemo( const vehicleOptions = useMemo(
() => () =>
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({ (Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
value: v.id, const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
label: `${v.manufacturer} ${v.model} (${v.plateNumber})`, if (v.code) parts.unshift(v.code);
})), const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
if (plates) parts.push(plates);
return { value: v.id, label: parts.join(" · ") };
}),
[vehiclesData], [vehiclesData],
); );

View File

@@ -18,10 +18,10 @@ export interface FirstMileBooking {
totalAmount: number; totalAmount: number;
scheduledDate?: string | null; scheduledDate?: string | null;
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null; company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
serviceType?: { id: string; name?: string } | null; serviceType?: { id: string; label?: string } | null;
originYard?: { id: string; name?: string } | null; originYard?: { id: string; label?: string } | null;
destinationYard?: { id: string; name?: string } | null; destinationYard?: { id: string; label?: string } | null;
cargoType?: { id: string; name?: string } | null; cargoType?: { id: string; label?: string } | null;
} }
export interface FirstMileVehicle { export interface FirstMileVehicle {
@@ -29,6 +29,9 @@ export interface FirstMileVehicle {
plateNumber: string; plateNumber: string;
manufacturer: string; manufacturer: string;
model: string; model: string;
code?: string | null;
powerPlateNo?: string | null;
trailerPlateNo?: string | null;
} }
export interface FirstMileRecord { export interface FirstMileRecord {

View File

@@ -29,6 +29,9 @@ export interface LastMileVehicle {
plateNumber: string; plateNumber: string;
manufacturer: string; manufacturer: string;
model: string; model: string;
code?: string | null;
powerPlateNo?: string | null;
trailerPlateNo?: string | null;
} }
export interface LastMileRecord { export interface LastMileRecord {

View File

@@ -26,6 +26,9 @@ export interface Vehicle {
capacity: number; capacity: number;
status: VehicleStatus; status: VehicleStatus;
description?: string | null; description?: string | null;
code?: string | null;
powerPlateNo?: string | null;
trailerPlateNo?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -1,3 +1,3 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; // export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001'; export const API_BASE_URL = 'http://localhost:3001';