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 ?? "—"} + + ))} + + + +