mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
Merge pull request #277 from Tria-plc/freight/feature/vehicle_2
Freight/feature/vehicle 2
This commit is contained in:
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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)} />
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user