diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index ba06604ce..1ac78355a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -45,7 +45,9 @@ export class FirstMileService { * unknown or the booking has not reached PAID status. */ async acceptBooking(bookingId: string): Promise { - const booking = await this.bookingsRepository.findById(bookingId); + const booking = await this.bookingsRepository.findById(bookingId, { + relations: { serviceType: true }, + }); if (!booking) { return null; @@ -55,6 +57,10 @@ export class FirstMileService { return null; } + if (!this.bookingRequestsFirstMile(booking)) { + return null; + } + return this.create({ bookingId: booking.id, advancedPayment: 0, @@ -62,7 +68,11 @@ export class FirstMileService { } async acceptBookingByReference(bookingReference: string): Promise { - const booking = await this.bookingsRepository.findByReference(bookingReference); + const [booking] = await this.bookingsRepository.findAll({ + where: { reference: bookingReference }, + relations: { serviceType: true }, + take: 1, + }); if (!booking) { return null; @@ -72,6 +82,10 @@ export class FirstMileService { return null; } + if (!this.bookingRequestsFirstMile(booking)) { + return null; + } + return this.create({ bookingId: booking.id, advancedPayment: 0, @@ -131,6 +145,11 @@ export class FirstMileService { } async create(dto: CreateFirstMileDto): Promise { + const existing = await this.findByBookingId(dto.bookingId); + if (existing) { + return existing; + } + return this.firstMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', @@ -142,6 +161,25 @@ export class FirstMileService { }); } + private async findByBookingId(bookingId: string): Promise { + const [records] = await this.firstMileRepository.findAndCount({ + where: { bookingId }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, + take: 1, + }); + return records[0] ?? null; + } + + private bookingRequestsFirstMile(booking: { + firstMilePickupAddress?: string | null; + serviceType?: { includesFirstMile?: boolean | null } | null; + }): boolean { + return Boolean(booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile); + } + async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 582e7467e..f4820704f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -344,10 +344,10 @@ export class PaymentService { ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } : { paymentStatus: "PAID", status: "PAID" }, ); - await this.firstMileService.acceptBooking(input.bookingId); - }); + await this.firstMileService.acceptBooking(input.bookingId); + if (isGeneralContract) { this.logger.log( `General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`, diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 9bb734512..460201639 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,5 +1,45 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; + +export class TruckEntranceDto { + @ApiProperty() + @IsString() + truckPlateNumber!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + trailerPlateNumber?: string; + + @ApiProperty() + @IsString() + driverName!: string; + + @ApiProperty() + @IsString() + driverPhone!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + driverLicenseNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + truckType?: string; + + @ApiProperty() + @IsNumber() + @Min(0) + entranceTareWeightKg!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + exitTareWeightKg?: number; +} /** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */ export class BulkReceiveDto { @@ -25,6 +65,9 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; + @ApiProperty({ type: TruckEntranceDto }) + truckEntrance!: TruckEntranceDto; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts index 46fecb044..e8a73190c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { TruckEntranceDto } from './bulk-receive.dto'; export class ReceiveWarehouseInventoryDto { @ApiProperty({ format: 'uuid' }) @@ -55,6 +56,9 @@ export class ReceiveWarehouseInventoryDto { @IsString() notes?: string; + @ApiProperty({ type: TruckEntranceDto }) + truckEntrance!: TruckEntranceDto; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 2d0b9711a..006efdb9c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -7,7 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; -import { BulkReceiveDto } from './dto/bulk-receive.dto'; +import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; @@ -199,12 +199,18 @@ export interface EligibleBookingRow { weight: string | null; paymentStatus: string; status: string; + hasFirstMile: boolean; + firstMileRequestId: string | null; + firstMileStatus: string | null; + firstMileVehicleId: string | null; + firstMileTruckPlateNumber: string | null; + firstMileTrailerPlateNumber: string | null; } export interface BulkReceiveResult { receivedCount: number; skippedCount: number; - results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[]; + results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[]; } export interface LoadPassedExportResult { @@ -646,13 +652,29 @@ export class WarehouseInventoryService { COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo", b.cargo_total_weight_vgm AS "weight", b.payment_status AS "paymentStatus", - b.status AS "status" + b.status AS "status", + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", + fm.id AS "firstMileRequestId", + fm.status AS "firstMileStatus", + fm.vehicle_id AS "firstMileVehicleId", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT first_mile.id, first_mile.status, first_mile.vehicle_id + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' AND inv.id IS NULL @@ -673,6 +695,7 @@ export class WarehouseInventoryService { /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ async bulkReceive(dto: BulkReceiveDto): Promise { const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; + this.assertTruckEntrance(dto.truckEntrance); await this.dataSource.transaction(async (manager) => { await this.validateLocation(manager, { @@ -689,10 +712,22 @@ export class WarehouseInventoryService { const [booking] = await manager.query( `SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight", - oy.country AS "originCountry", dy.country AS "destinationCountry" + oy.country AS "originCountry", dy.country AS "destinationCountry", + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", + fm.id AS "firstMileRequestId", + fm.status AS "firstMileStatus" FROM freight.bookings b LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id + LEFT JOIN LATERAL ( + SELECT first_mile.id, first_mile.status + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); @@ -707,10 +742,28 @@ export class WarehouseInventoryService { skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); continue; } + if (dto.direction === 'EXPORT' && booking.hasFirstMile) { + if (!booking.firstMileRequestId) { + skip('First-mile request not created'); + continue; + } + if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') { + skip('First-mile truck has not arrived'); + continue; + } + } const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); if (existing) { skip('Already received'); continue; } + const now = new Date(); + const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const receiveNote = this.buildReceiveNote({ + grnNumber, + direction: dto.direction, + notes: `Bulk received (${dto.direction})`, + truckEntrance: dto.truckEntrance, + }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, @@ -720,8 +773,8 @@ export class WarehouseInventoryService { quantity: 1, weight: Number(booking.weight) || 0, status: 'RECEIVED', - arrivedAt: new Date(), - notes: `Bulk received (${dto.direction})`, + arrivedAt: now, + notes: receiveNote, }), ); @@ -730,14 +783,14 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `Bulk received ${dto.direction} booking`, + description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`, performedBy: dto.performedBy, }, manager, ); result.receivedCount += 1; - result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id }); + result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); } }); @@ -1430,9 +1483,11 @@ export class WarehouseInventoryService { } async receive(dto: ReceiveWarehouseInventoryDto): Promise { + this.assertTruckEntrance(dto.truckEntrance); const weight = Number(dto.weight) || 0; const volume = Number(dto.volume) || 0; const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0; + const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null; const id = await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, dto); @@ -1446,6 +1501,12 @@ export class WarehouseInventoryService { this.assertCapacity('Zone', zone, weight, volume, containerCount); const now = new Date(); + const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now); + const receiveNote = this.buildReceiveNote({ + grnNumber, + notes: dto.notes?.trim() || 'Single booking received', + truckEntrance: dto.truckEntrance, + }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, @@ -1460,7 +1521,7 @@ export class WarehouseInventoryService { volume: dto.volume ?? null, status: 'RECEIVED', arrivedAt: now, - notes: dto.notes?.trim() ?? null, + notes: receiveNote, }), ); @@ -1471,7 +1532,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `Received ${weight}kg at warehouse location`, + description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`, performedBy: dto.performedBy, }, manager, @@ -2349,26 +2410,28 @@ export class WarehouseInventoryService { Warehouse Gate Clearance / Release Order @@ -2385,6 +2448,7 @@ export class WarehouseInventoryService { Issued: ${esc(issuedAt)} +
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
@@ -2400,15 +2464,10 @@ export class WarehouseInventoryService { cargo details, clearance status, and payment records before permitting exit from the warehouse premises.
-
-
EDR
Warehouse
Cleared
-
Officer in charge name / signature / date
-
+
Officer in charge name / signature / date
+
EDR
Warehouse
Cleared
Customer or driver name / signature / date
- `; @@ -2448,6 +2507,50 @@ export class WarehouseInventoryService { return trimmed ? `${trimmed}\n${note}` : note; } + private assertTruckEntrance(truckEntrance?: TruckEntranceDto): void { + if (!truckEntrance?.truckPlateNumber?.trim()) { + throw new BadRequestException('Truck plate number is required for entrance registration'); + } + if (!truckEntrance.driverName?.trim()) { + throw new BadRequestException('Driver name is required for entrance registration'); + } + if (!truckEntrance.driverPhone?.trim()) { + throw new BadRequestException('Driver phone is required for entrance registration'); + } + if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) { + throw new BadRequestException('Entrance tare weight is required for entrance registration'); + } + } + + private generateGrnNumber(direction: string, referenceId: string, date: Date): string { + const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); + const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); + return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + } + + private buildReceiveNote(input: { + grnNumber: string; + direction?: string | null; + notes?: string | null; + truckEntrance: TruckEntranceDto; + }): string { + const truck = input.truckEntrance; + const rows = [ + `GRN Number: ${input.grnNumber}`, + input.direction ? `Direction: ${input.direction}` : null, + `Truck Plate: ${truck.truckPlateNumber}`, + truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, + truck.truckType ? `Truck Type: ${truck.truckType}` : null, + `Driver: ${truck.driverName}`, + `Driver Phone: ${truck.driverPhone}`, + truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, + `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`, + truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, + input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null, + ]; + return rows.filter(Boolean).join('\n'); + } + private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise { const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index bf15a4ab8..293b38dd7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -18,16 +18,20 @@ import { } from '@mantine/core'; import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; +import { firstMileService } from '@/services/first-mile.service'; import type { + EligibleBooking, ImportTrain, ImportTrainItem, ImportUnloadedItem, ReadyToLoadRow, ReceiveInventoryPayload, + TruckEntrancePayload, } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; import { InspectionReportModal } from './InspectionReportModal'; @@ -49,6 +53,106 @@ interface Location { zoneId: string; } +interface TruckEntranceFormState { + truckPlateNumber: string; + trailerPlateNumber: string; + driverName: string; + driverPhone: string; + driverLicenseNumber: string; + truckType: string; + entranceTareWeightKg: number | ''; + exitTareWeightKg: number | ''; +} + +const emptyTruckEntrance = (): TruckEntranceFormState => ({ + truckPlateNumber: '', + trailerPlateNumber: '', + driverName: '', + driverPhone: '', + driverLicenseNumber: '', + truckType: '', + entranceTareWeightKg: '', + exitTareWeightKg: '', +}); + +const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({ + truckPlateNumber: form.truckPlateNumber.trim(), + trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, + driverName: form.driverName.trim(), + driverPhone: form.driverPhone.trim(), + driverLicenseNumber: form.driverLicenseNumber.trim() || undefined, + truckType: form.truckType.trim() || undefined, + entranceTareWeightKg: Number(form.entranceTareWeightKg), + exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg), +}); + +function TruckEntranceFields({ + value, + onChange, +}: { + value: TruckEntranceFormState; + onChange: (next: TruckEntranceFormState) => void; +}) { + return ( + + + onChange({ ...value, truckPlateNumber: e.currentTarget.value })} + /> + onChange({ ...value, trailerPlateNumber: e.currentTarget.value })} + /> + + + onChange({ ...value, driverName: e.currentTarget.value })} + /> + onChange({ ...value, driverPhone: e.currentTarget.value })} + /> + + + onChange({ ...value, driverLicenseNumber: e.currentTarget.value })} + /> + onChange({ ...value, truckType: e.currentTarget.value })} + /> + + + onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })} + /> + onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })} + /> + + + ); +} + /** Cascading Warehouse → Yard → Zone selectors (ACTIVE only). */ function LocationSelects({ value, @@ -140,20 +244,105 @@ function EligibleTab({ onChanged?: () => void; }) { const { toast } = useToast(); + const qc = useQueryClient(); const { data: allRows = [], isLoading } = useQuery( api.warehouses.eligibleBookings.queryOptions({ enabled }), ); const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]); const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions()); const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions()); + const requestFirstMile = useMutation({ + mutationFn: (reference: string) => firstMileService.accept(reference), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + toast({ title: 'First mile requested', description: 'Booking was added to the existing First Mile workflow.' }); + }, + onError: (error) => { + toast({ variant: 'destructive', title: 'First mile request failed', description: extractErrorMessage(error) }); + }, + }); const [selected, setSelected] = useState>(new Set()); + const [statusTab, setStatusTab] = useState('ALL'); + const [truckOpen, setTruckOpen] = useState(false); + const [pendingReceiveIds, setPendingReceiveIds] = useState([]); + const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); - const allSelected = rows.length > 0 && selected.size === rows.length; + const canReceiveBooking = (row: EligibleBooking) => + !(direction === 'EXPORT' && row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT'); + const statusOptions = useMemo(() => { + const base = [{ value: 'ALL', label: 'All bookings' }]; + if (direction === 'EXPORT') { + return [ + ...base, + { value: 'DIRECT', label: 'Direct truck' }, + { value: 'FIRST_MILE', label: 'First mile' }, + { value: 'FIRST_MILE_READY', label: 'First mile arrived' }, + { value: 'AWAITING_FIRST_MILE', label: 'Awaiting first mile' }, + ]; + } + return [ + ...base, + { value: 'READY_TO_RECEIVE', label: 'Ready to receive' }, + { value: 'PAID', label: 'Paid' }, + ]; + }, [direction]); + const statusFilteredRows = useMemo( + () => + rows.filter((row) => { + switch (statusTab) { + case 'DIRECT': + return !row.hasFirstMile; + case 'FIRST_MILE': + return row.hasFirstMile; + case 'FIRST_MILE_READY': + return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT'; + case 'AWAITING_FIRST_MILE': + return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT'; + case 'READY_TO_RECEIVE': + return canReceiveBooking(row); + case 'PAID': + return row.paymentStatus === 'PAID'; + default: + return true; + } + }), + [rows, statusTab], + ); + const statusCounts = useMemo( + () => + Object.fromEntries( + statusOptions.map((option) => [ + option.value, + rows.filter((row) => { + switch (option.value) { + case 'DIRECT': + return !row.hasFirstMile; + case 'FIRST_MILE': + return row.hasFirstMile; + case 'FIRST_MILE_READY': + return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT'; + case 'AWAITING_FIRST_MILE': + return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT'; + case 'READY_TO_RECEIVE': + return canReceiveBooking(row); + case 'PAID': + return row.paymentStatus === 'PAID'; + default: + return true; + } + }).length, + ]), + ), + [rows, statusOptions], + ); + const selectableRows = statusFilteredRows.filter(canReceiveBooking); + const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length; const someSelected = selected.size > 0 && !allSelected; const toggleAll = () => - setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id))); + setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id))); const toggleOne = (id: string) => setSelected((prev) => { const next = new Set(prev); @@ -161,7 +350,7 @@ function EligibleTab({ return next; }); - const receive = async (bookingIds: string[]) => { + const openTruckReceive = (bookingIds: string[]) => { if (!locationReady) { toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' }); return; @@ -170,13 +359,41 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'Select at least one booking' }); return; } + const allowedIds = new Set(selectableRows.map((row) => row.id)); + const filteredIds = bookingIds.filter((id) => allowedIds.has(id)); + if (filteredIds.length === 0) { + toast({ variant: 'destructive', title: 'No selected booking is ready to receive' }); + return; + } + const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null; + setPendingReceiveIds(filteredIds); + setTruckForm({ + ...emptyTruckEntrance(), + truckPlateNumber: row?.firstMileTruckPlateNumber ?? '', + trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '', + }); + setTruckOpen(true); + }; + + const receive = async () => { + if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') { + toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); + return; + } try { - const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds }); + const r = await bulkReceive.mutateAsync({ + direction, + ...location, + bookingIds: pendingReceiveIds, + truckEntrance: toTruckEntrancePayload(truckForm), + }); toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), }); setSelected(new Set()); + setTruckOpen(false); + setPendingReceiveIds([]); onChanged?.(); } catch (error) { toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); @@ -198,9 +415,19 @@ function EligibleTab({ return ( + setStatusTab(v ?? 'ALL')}> + + {statusOptions.map((option) => ( + + {option.label} ({statusCounts[option.value] ?? 0}) + + ))} + + + - Selected: {selected.size} / {rows.length} eligible + Selected: {selected.size} / {statusFilteredRows.length} eligible {direction === 'EXPORT' && ( @@ -218,9 +445,9 @@ function EligibleTab({ @@ -228,7 +455,7 @@ function EligibleTab({ size="compact-sm" disabled={!locationReady || selected.size === 0} loading={bulkReceive.isPending} - onClick={() => receive([...selected])} + onClick={() => openTruckReceive([...selected])} > Receive Selected @@ -245,7 +472,7 @@ function EligibleTab({ - ) : rows.length === 0 ? ( + ) : statusFilteredRows.length === 0 ? ( No eligible PAID {direction.toLowerCase()} bookings to receive. @@ -275,16 +502,20 @@ function EligibleTab({ Payment Current Status Inspection + {direction === 'EXPORT' && First Mile} Actions - {rows.map((r) => ( + {statusFilteredRows.map((r) => { + const canReceive = canReceiveBooking(r); + return ( toggleOne(r.id)} /> @@ -319,23 +550,73 @@ function EligibleTab({ + {direction === 'EXPORT' && ( + + {r.hasFirstMile ? ( + + + {r.firstMileStatus ?? 'Request needed'} + + + {[r.firstMileTruckPlateNumber, r.firstMileTrailerPlateNumber].filter(Boolean).join(' / ') || 'Truck not assigned'} + + + ) : ( + Direct arrival + )} + + )} - + {direction === 'EXPORT' && r.hasFirstMile && !r.firstMileRequestId ? ( + + ) : ( + + )} - ))} + ); + })} )} + + setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg"> + + + Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}. + + + + + + + + ); } @@ -1203,11 +1484,13 @@ function SingleBookingReceiveModal({ volume: '', notes: '', }); + const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); useEffect(() => { if (opened) { setSelectedBooking(bookingId ?? ''); setForm({ warehouseId: '', yardId: '', zoneId: '', quantity: '', weight: '', volume: '', notes: '' }); + setTruckForm(emptyTruckEntrance()); } }, [opened, bookingId]); @@ -1226,6 +1509,10 @@ function SingleBookingReceiveModal({ toast({ variant: 'destructive', title: 'Quantity and weight are required' }); return; } + if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') { + toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); + return; + } const payload: ReceiveInventoryPayload = { bookingId: selectedBooking.trim(), warehouseId: form.warehouseId, @@ -1235,6 +1522,7 @@ function SingleBookingReceiveModal({ weight: Number(form.weight), volume: form.volume === '' ? undefined : Number(form.volume), notes: form.notes.trim() || undefined, + truckEntrance: toTruckEntrancePayload(truckForm), }; try { await receiveMutation.mutateAsync(payload); @@ -1281,6 +1569,8 @@ function SingleBookingReceiveModal({ /> + +