This commit is contained in:
natib21
2026-07-03 13:49:55 +00:00
parent 48db0b240b
commit 782f4184ef
8 changed files with 142 additions and 2 deletions

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Allow more than one vehicle per last-mile delivery. Junction table joins
* last_mile ⇄ vehicles; existing single vehicle_id values are backfilled as
* the first assignment so nothing is lost.
*/
export class AddLastMileVehicleAssignments1890000000004 implements MigrationInterface {
name = "AddLastMileVehicleAssignments1890000000004";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
last_mile_id uuid NOT NULL REFERENCES freight.last_mile(id) ON DELETE CASCADE,
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "UQ_LAST_MILE_VEHICLE" UNIQUE (last_mile_id, vehicle_id)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_LM_VEHICLE_ASSIGNMENTS_VEHICLE"
ON freight.last_mile_vehicle_assignments (vehicle_id)
`);
// Backfill: existing single-vehicle assignments become the first row
await queryRunner.query(`
INSERT INTO freight.last_mile_vehicle_assignments (last_mile_id, vehicle_id)
SELECT id, vehicle_id FROM freight.last_mile
WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL
ON CONFLICT (last_mile_id, vehicle_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Store the driver's gender. Prefilled from the Fayda VERIFY response
* (Male/Female) but editable; nullable so existing rows and manual,
* non-Fayda driver records stay valid.
*/
export class AddDriverGender1890000000005 implements MigrationInterface {
name = "AddDriverGender1890000000005";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
ADD COLUMN IF NOT EXISTS gender varchar
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
DROP COLUMN IF EXISTS gender
`);
}
}

View File

@@ -1,5 +1,5 @@
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator';
import { DriverStatus } from '../entities/driver.entity';
import { DriverStatus, DriverGender } from '../entities/driver.entity';
export class CreateDriverDto {
@IsString()
@@ -20,6 +20,10 @@ export class CreateDriverDto {
@IsDateString()
dateOfBirth!: string;
@IsOptional()
@IsEnum(DriverGender)
gender?: DriverGender;
@IsDateString()
licenseExpiryDate!: string;

View File

@@ -8,6 +8,12 @@ export enum DriverStatus {
ON_LEAVE = 'ON_LEAVE',
}
export enum DriverGender {
MALE = 'MALE',
FEMALE = 'FEMALE',
OTHER = 'OTHER',
}
@Entity({ name: 'drivers', schema: 'freight' })
export class Driver extends BaseEntity {
@Column({ name: 'license_number', unique: true, nullable: true })
@@ -28,6 +34,9 @@ export class Driver extends BaseEntity {
@Column({ name: 'date_of_birth', type: 'date', nullable: true })
dateOfBirth?: Date;
@Column({ type: 'varchar', nullable: true })
gender?: DriverGender | null;
@Column({ name: 'license_expiry_date', type: 'date', nullable: true })
licenseExpiryDate?: Date;

View File

@@ -0,0 +1,30 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { LastMile } from './last-mile.entity';
/**
* One row per vehicle assigned to a last-mile delivery. A delivery can be
* served by several vehicles at once (multi-truck bookings); the legacy
* `last_mile.vehicle_id` column keeps pointing at the first assignment for
* backward compatibility.
*/
@Entity({ name: 'last_mile_vehicle_assignments', schema: 'freight' })
@Unique(['lastMileId', 'vehicleId'])
@Index(['vehicleId'])
export class LastMileVehicleAssignment extends BaseEntity {
@Column({ name: 'last_mile_id', type: 'uuid' })
lastMileId!: string;
@ManyToOne(() => LastMile, (lm) => lm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'last_mile_id' })
lastMile?: LastMile;
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { nullable: false, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
}

View File

@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity';
export const LAST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -57,4 +58,7 @@ export class LastMile extends BaseEntity {
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
containerAllocations?: LastMileContainerAllocation[];
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
vehicleAssignments?: LastMileVehicleAssignment[];
}

View File

@@ -45,6 +45,24 @@ export interface FleetFormDialogProps {
verifyWithFayda?: boolean;
}
// Fayda returns gender as "Male"/"Female"; snap it onto the form's uppercase
// option values (MALE/FEMALE/OTHER) so the Select prefills instead of rendering
// blank. Unknown/empty values fall through to undefined (field left untouched).
const normalizeGender = (raw?: string): string | undefined => {
const up = (raw ?? "").trim().toUpperCase();
if (up === "MALE" || up === "M") return "MALE";
if (up === "FEMALE" || up === "F") return "FEMALE";
return up ? "OTHER" : undefined;
};
// Fayda may return the birthdate as "2001/12/01" (slashes), but the date input
// and validator expect ISO "2001-12-01". Normalize separators + trim to 10 chars
// so the DOB field prefills instead of silently staying blank.
const normalizeBirthdate = (raw?: string): string | undefined => {
const iso = (raw ?? "").trim().replace(/\//g, "-").slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : undefined;
};
const buildInitialValues = (
fields: FleetFormFieldDef[],
emptyValues: Record<string, unknown>,
@@ -131,13 +149,16 @@ const FleetFormDialog = ({
}
const nameParts = (result.fullName ?? "").trim().split(/\s+/).filter(Boolean);
const [firstName, ...rest] = nameParts;
const gender = normalizeGender(result.gender);
const dateOfBirth = normalizeBirthdate(result.birthdate);
setValues((current) => ({
...current,
...(firstName ? { firstName } : {}),
...(rest.length ? { lastName: rest.join(" ") } : {}),
...(result.email ? { email: result.email } : {}),
...(result.phoneNumber ? { phoneNumber: result.phoneNumber } : {}),
...(result.birthdate ? { dateOfBirth: result.birthdate } : {}),
...(dateOfBirth ? { dateOfBirth } : {}),
...(gender ? { gender } : {}),
faydaVerified: true,
...(result.iamUserId ? { faydaSub: result.iamUserId } : {}),
}));

View File

@@ -8,6 +8,12 @@ const DRIVER_STATUS_OPTIONS = [
{ label: "On leave", value: "ON_LEAVE" },
];
const DRIVER_GENDER_OPTIONS = [
{ label: "Male", value: "MALE" },
{ label: "Female", value: "FEMALE" },
{ label: "Other", value: "OTHER" },
];
export const driversConfig: FleetResourceConfig = {
slug: "drivers",
label: "Drivers",
@@ -37,6 +43,7 @@ export const driversConfig: FleetResourceConfig = {
{ id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 },
{ id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 },
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 },
{ id: "gender", header: "Gender", accessorKey: "gender", format: "code", size: 90 },
{ id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
{ id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 },
@@ -48,6 +55,7 @@ export const driversConfig: FleetResourceConfig = {
{ name: "email", label: "Email", type: "email", required: true },
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true },
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true },
{ name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS },
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true },
{ name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS },
{ name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS },
@@ -62,6 +70,7 @@ export const driversConfig: FleetResourceConfig = {
email: "",
phoneNumber: "",
dateOfBirth: "",
gender: "",
licenseExpiryDate: "",
status: "ACTIVE",
vehicleTypesAuthorized: [],