Merge pull request #291 from Tria-plc/freight/feature/vehicle_2

fix
This commit is contained in:
yaschalew10
2026-06-25 12:28:40 +03:00
committed by GitHub
2 changed files with 248 additions and 21 deletions

View File

@@ -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<boolean> {
@@ -13,24 +15,43 @@ export class SmsNotificationStrategy implements NotificationStrategy {
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",
},
},
);
const appKey = this.configService.get<string>("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<string>("OZIKING_SOURCE_ID") ?? "EDR",
sourceName: this.configService.get<string>("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;
}
}
}

View File

@@ -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<string | null>(null);
const [vehicleValue, setVehicleValue] = useState<string | null>(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<ArrivalQueueItem[]>([]);
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(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})
</Button>
)}
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)} styles={{ label: { fontWeight: 500 } }}>
<Button leftSection={<Truck size={16} />} onClick={openAccept} styles={{ label: { fontWeight: 500 } }}>
Assign Mile
</Button>
</Group>
@@ -688,6 +768,132 @@ const LastMilePage = () => {
</Stack>
</Card>
{/* 2-step Assign Mile (arrival queue → vehicle) */}
<Modal
opened={acceptOpen}
onClose={closeAccept}
title={
<Text fw={600}>
{acceptStep === 1 ? "Select Arrivals" : "Assign Mile"}
</Text>
}
size="xl"
radius="lg"
centered
>
{acceptStep === 1 ? (
<Stack gap="md">
<TextInput
placeholder="Search by reference, customer, cargo or warehouse…"
value={arrivalSearch}
onChange={(e) => setArrivalSearch(e.currentTarget.value)}
/>
<ScrollArea h={400}>
<Stack gap="xs">
{arrivalLoading ? (
<Text c="dimmed" size="sm" ta="center" py="md">Loading arrivals</Text>
) : filteredArrivalQueue.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" py="md">No arrivals in queue.</Text>
) : (
filteredArrivalQueue.map((item) => {
const checked = selectedArrivalItems.some((i) => i.bookingId === item.bookingId);
return (
<Card
key={item.bookingId}
withBorder
padding="sm"
radius="md"
style={{
cursor: "pointer",
borderColor: checked ? "var(--mantine-color-blue-4)" : "var(--mantine-color-gray-3)",
backgroundColor: checked ? "var(--mantine-color-blue-0)" : "var(--mantine-color-white)",
transition: "background-color 120ms ease, border-color 120ms ease",
}}
onClick={() => toggleArrivalItem(item)}
>
<Group wrap="nowrap" gap="sm">
<Checkbox
checked={checked}
onChange={() => toggleArrivalItem(item)}
onClick={(e) => e.stopPropagation()}
/>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap">
<Text fw={700} size="sm">{item.bookingReference}</Text>
<Text size="xs" c="dimmed">{item.arrivalDate ? item.arrivalDate.slice(0, 10) : "—"}</Text>
</Group>
<Text size="xs" c="dimmed" truncate>{item.customer ?? "—"}</Text>
<Group gap="xs" wrap="wrap">
{item.cargo && <Text size="xs" c="dimmed">{item.cargo}</Text>}
{item.warehouse && <Text size="xs" c="dimmed">· {item.warehouse}</Text>}
{item.yard && <Text size="xs" c="dimmed">· {item.yard}</Text>}
</Group>
</Stack>
</Group>
</Card>
);
})
)}
</Stack>
</ScrollArea>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{selectedArrivalItems.length > 0
? `${selectedArrivalItems.length} selected`
: "Select one or more arrivals"}
</Text>
<Group gap="sm">
<Button variant="default" onClick={closeAccept}>Cancel</Button>
<Button
disabled={selectedArrivalItems.length === 0}
onClick={() => setAcceptStep(2)}
>
Next
</Button>
</Group>
</Group>
</Stack>
) : (
<Stack gap="md">
<Card withBorder padding="sm" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="xs">
<Text size="sm" fw={600} c="dimmed">Selected arrivals ({selectedArrivalItems.length})</Text>
{selectedArrivalItems.map((item) => (
<Group key={item.bookingId} justify="space-between" wrap="nowrap">
<Text size="sm" fw={600}>{item.bookingReference}</Text>
<Text size="xs" c="dimmed">{item.customer ?? "—"}</Text>
<Text size="xs" c="dimmed">{item.warehouse ?? item.yard ?? "—"}</Text>
</Group>
))}
</Stack>
</Card>
<Divider />
<Select
label="Assign Vehicle (optional)"
placeholder="Select a vehicle"
data={vehicleOptions}
value={acceptVehicleValue}
onChange={setAcceptVehicleValue}
searchable
clearable
/>
<Group justify="space-between" gap="sm">
<Button variant="subtle" onClick={() => setAcceptStep(1)}> Back</Button>
<Group gap="sm">
<Button variant="default" onClick={closeAccept}>Cancel</Button>
<Button
onClick={handleAcceptConfirm}
loading={acceptMutation.isPending}
disabled={selectedArrivalItems.length === 0}
>
Accept {selectedArrivalItems.length > 1 ? `${selectedArrivalItems.length} Deliveries` : "Delivery"}
</Button>
</Group>
</Group>
</Stack>
)}
</Modal>
{/* Assign / Reassign modal */}
<Modal
opened={assignOpen}