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 a00e54fff..ba06604ce 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 @@ -133,7 +133,7 @@ export class FirstMileService { async create(dto: CreateFirstMileDto): Promise { return this.firstMileRepository.create({ bookingId: dto.bookingId, - status: dto.status ?? 'PAYMENT_PENDING', + status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? null, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 35eb17356..badb87f4c 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,10 @@ +<<<<<<< HEAD import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource, FindOptionsWhere } from 'typeorm'; +======= +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere } from 'typeorm'; +>>>>>>> 73de29f7c5977827a68865b9cb492a5a2cf1cead import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -40,52 +45,15 @@ export class LastMileService { private readonly notificationsService: NotificationsService, ) {} - async acceptBooking(bookingReference: string): Promise { + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); if (!booking) { - throw new NotFoundException(`Booking ${bookingReference} not found`); + return null; } if (booking.paymentStatus !== 'PAID') { - throw new BadRequestException( - `Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`, - ); - } - - if ((booking.tradeDirection ?? '').toUpperCase() !== 'IMPORT') { - throw new BadRequestException(`Booking ${bookingReference} is not an import booking`); - } - - if (!booking.lastMileDeliveryAddress?.trim()) { - throw new BadRequestException(`Booking ${bookingReference} has no last-mile delivery address`); - } - - const [eligibleInventory] = await this.dataSource.query( - `SELECT inv.id - FROM freight.warehouse_inventory inv - WHERE inv.booking_id = $1 - AND inv.deleted_at IS NULL - AND inv.status = 'READY_FOR_PICKUP' - AND inv.inspection_status = 'PASSED' - LIMIT 1`, - [booking.id], - ); - if (!eligibleInventory) { - throw new BadRequestException( - `Booking ${bookingReference} is not eligible for last mile. Import inventory must pass inspection and be READY_FOR_PICKUP.`, - ); - } - - const [existing] = await this.dataSource.query( - `SELECT id - FROM freight.last_mile - WHERE booking_id = $1 AND deleted_at IS NULL - LIMIT 1`, - [booking.id], - ); - if (existing) { - return this.findById(existing.id); + return null; } return this.create({ @@ -94,17 +62,15 @@ export class LastMileService { }); } - async acceptBookingByReference(bookingReference: string): Promise { + async acceptBookingByReference(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); if (!booking) { - throw new NotFoundException(`Booking ${bookingReference} not found`); + return null; } if (booking.paymentStatus !== 'PAID') { - throw new BadRequestException( - `Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`, - ); + return null; } return this.create({ @@ -169,7 +135,7 @@ export class LastMileService { async create(dto: CreateLastMileDto): Promise { return this.lastMileRepository.create({ bookingId: dto.bookingId, - status: dto.status ?? 'PAYMENT_PENDING', + status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? null, diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index 127eae5cd..d8404e84c 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -1,11 +1,13 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import axios from "axios"; +import axios, { isAxiosError } from "axios"; import { NotificationStrategy } from "./notification.strategy"; @Injectable() export class SmsNotificationStrategy implements NotificationStrategy { + private readonly logger = new Logger(SmsNotificationStrategy.name); + constructor(private readonly configService: ConfigService) {} async send(recipient: string, message: string): Promise { @@ -13,24 +15,43 @@ export class SmsNotificationStrategy implements NotificationStrategy { this.configService.get("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("OZIKING_SOURCE_ID") ?? "EDR", - sourceName: this.configService.get("OZIKING_SOURCE_NAME") ?? "EDR Freight", - appKey: this.configService.get("OZIKING_APP_KEY") ?? "", - text: message, - callbackUrl: "", - }, - { - headers: { - accept: "*/*", - "Content-Type": "application/json", - }, - }, - ); + const appKey = this.configService.get("OZIKING_APP_KEY") ?? ""; + if (!appKey) { + this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API"); + } - return true; + this.logger.debug(`Sending SMS to ${recipient} via ${url}`); + + try { + const response = await axios.post( + url, + { + to: recipient, + sourceId: this.configService.get("OZIKING_SOURCE_ID") ?? "EDR", + sourceName: this.configService.get("OZIKING_SOURCE_NAME") ?? "EDR Freight", + appKey, + text: message, + callbackUrl: "", + }, + { + headers: { + accept: "*/*", + "Content-Type": "application/json", + }, + }, + ); + + this.logger.debug(`SMS API response: ${response.status} ${JSON.stringify(response.data)}`); + return true; + } catch (err) { + if (isAxiosError(err)) { + this.logger.error( + `SMS API error: ${err.message} | status=${err.response?.status} | body=${JSON.stringify(err.response?.data)}`, + ); + } else { + this.logger.error(`SMS send failed: ${String(err)}`); + } + throw err; + } } } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index f7807f90a..bb121ddfc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -21,12 +21,15 @@ import { Group, Menu, Modal, + ScrollArea, Select, SimpleGrid, Stack, Text, TextInput, } from "@mantine/core"; +import type { ArrivalQueueItem } from "@/types/warehouse"; +import { warehouseService } from "@/services/warehouse.service"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; @@ -297,6 +300,13 @@ const LastMilePage = () => { const [activeId, setActiveId] = useState(null); const [vehicleValue, setVehicleValue] = useState(null); + // 2-step "Assign Mile" accept modal (arrival queue → vehicle) + const [acceptOpen, setAcceptOpen] = useState(false); + const [acceptStep, setAcceptStep] = useState<1 | 2>(1); + const [selectedArrivalItems, setSelectedArrivalItems] = useState([]); + const [acceptVehicleValue, setAcceptVehicleValue] = useState(null); + const [arrivalSearch, setArrivalSearch] = useState(""); + const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.LAST_MILE.list(), queryFn: async () => { @@ -338,6 +348,76 @@ const LastMilePage = () => { }, }); + const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({ + queryKey: ["warehouse-inventory", "arrival-queue"], + queryFn: () => warehouseService.arrivalQueue().then((r) => r.data), + enabled: acceptOpen, + }); + const arrivalQueue = arrivalQueueData ?? []; + + const filteredArrivalQueue = useMemo(() => { + const term = arrivalSearch.trim().toLowerCase(); + if (!term) return arrivalQueue; + return arrivalQueue.filter((item) => + [item.bookingReference, item.customer, item.cargo, item.warehouse, item.yard] + .join(" ") + .toLowerCase() + .includes(term), + ); + }, [arrivalQueue, arrivalSearch]); + + const acceptMutation = useMutation({ + mutationFn: async ({ items, vehicleId }: { items: ArrivalQueueItem[]; vehicleId: string | null }) => { + const created = await Promise.all( + items.map((item) => lastMileService.accept(item.bookingReference).then((r) => r.data)), + ); + if (vehicleId) { + await Promise.all(created.map((record) => lastMileService.update(record.id, { vehicleId }))); + } + return created; + }, + onSuccess: (created) => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + toast({ + title: "Last-mile leg created", + description: `${created.length} ${created.length === 1 ? "delivery" : "deliveries"} accepted successfully.`, + }); + closeAccept(); + }, + onError: () => { + toast({ title: "Accept failed", variant: "destructive" }); + }, + }); + + const openAccept = () => { + setAcceptOpen(true); + setAcceptStep(1); + setSelectedArrivalItems([]); + setAcceptVehicleValue(null); + setArrivalSearch(""); + }; + + const closeAccept = () => { + setAcceptOpen(false); + setAcceptStep(1); + setSelectedArrivalItems([]); + setAcceptVehicleValue(null); + setArrivalSearch(""); + }; + + const toggleArrivalItem = (item: ArrivalQueueItem) => { + setSelectedArrivalItems((prev) => + prev.some((i) => i.bookingId === item.bookingId) + ? prev.filter((i) => i.bookingId !== item.bookingId) + : [...prev, item], + ); + }; + + const handleAcceptConfirm = () => { + if (!selectedArrivalItems.length) return; + acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue }); + }; + const activeRecord = useMemo( () => records.find((r) => r.id === activeId) ?? null, [records, activeId], @@ -633,7 +713,7 @@ const LastMilePage = () => { Assign vehicle ({selectedIds.length}) )} - @@ -688,6 +768,132 @@ const LastMilePage = () => { + {/* 2-step Assign Mile (arrival queue → vehicle) */} + + {acceptStep === 1 ? "Select Arrivals" : "Assign Mile"} + + } + size="xl" + radius="lg" + centered + > + {acceptStep === 1 ? ( + + setArrivalSearch(e.currentTarget.value)} + /> + + + {arrivalLoading ? ( + Loading arrivals… + ) : filteredArrivalQueue.length === 0 ? ( + No arrivals in queue. + ) : ( + filteredArrivalQueue.map((item) => { + const checked = selectedArrivalItems.some((i) => i.bookingId === item.bookingId); + return ( + toggleArrivalItem(item)} + > + + toggleArrivalItem(item)} + onClick={(e) => e.stopPropagation()} + /> + + + {item.bookingReference} + {item.arrivalDate ? item.arrivalDate.slice(0, 10) : "—"} + + {item.customer ?? "—"} + + {item.cargo && {item.cargo}} + {item.warehouse && · {item.warehouse}} + {item.yard && · {item.yard}} + + + + + ); + }) + )} + + + + + {selectedArrivalItems.length > 0 + ? `${selectedArrivalItems.length} selected` + : "Select one or more arrivals"} + + + + + + + + ) : ( + + + + Selected arrivals ({selectedArrivalItems.length}) + {selectedArrivalItems.map((item) => ( + + {item.bookingReference} + {item.customer ?? "—"} + {item.warehouse ?? item.yard ?? "—"} + + ))} + + + +