mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user