mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -133,7 +133,7 @@ export class FirstMileService {
|
|||||||
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
|
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
|
||||||
return this.firstMileRepository.create({
|
return this.firstMileRepository.create({
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
status: dto.status ?? 'PAYMENT_PENDING',
|
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||||
advancedPayment: dto.advancedPayment ?? 0,
|
advancedPayment: dto.advancedPayment ?? 0,
|
||||||
remainingPayment: dto.remainingPayment ?? 0,
|
remainingPayment: dto.remainingPayment ?? 0,
|
||||||
estimatedKm: dto.estimatedKm ?? null,
|
estimatedKm: dto.estimatedKm ?? null,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere } from 'typeorm';
|
import { FindOptionsWhere } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
@@ -39,52 +39,15 @@ export class LastMileService {
|
|||||||
private readonly notificationsService: NotificationsService,
|
private readonly notificationsService: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async acceptBooking(bookingReference: string): Promise<LastMile> {
|
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking ${bookingReference} not found`);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.paymentStatus !== 'PAID') {
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
throw new BadRequestException(
|
return null;
|
||||||
`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 this.create({
|
return this.create({
|
||||||
@@ -93,17 +56,15 @@ export class LastMileService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async acceptBookingByReference(bookingReference: string): Promise<LastMile> {
|
async acceptBookingByReference(bookingReference: string): Promise<LastMile | null> {
|
||||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking ${bookingReference} not found`);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.paymentStatus !== 'PAID') {
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
throw new BadRequestException(
|
return null;
|
||||||
`Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.create({
|
return this.create({
|
||||||
@@ -168,7 +129,7 @@ export class LastMileService {
|
|||||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||||
return this.lastMileRepository.create({
|
return this.lastMileRepository.create({
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
status: dto.status ?? 'PAYMENT_PENDING',
|
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||||
advancedPayment: dto.advancedPayment ?? 0,
|
advancedPayment: dto.advancedPayment ?? 0,
|
||||||
remainingPayment: dto.remainingPayment ?? 0,
|
remainingPayment: dto.remainingPayment ?? 0,
|
||||||
estimatedKm: dto.estimatedKm ?? null,
|
estimatedKm: dto.estimatedKm ?? null,
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import axios from "axios";
|
import axios, { isAxiosError } from "axios";
|
||||||
|
|
||||||
import { NotificationStrategy } from "./notification.strategy";
|
import { NotificationStrategy } from "./notification.strategy";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsNotificationStrategy implements NotificationStrategy {
|
export class SmsNotificationStrategy implements NotificationStrategy {
|
||||||
|
private readonly logger = new Logger(SmsNotificationStrategy.name);
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
async send(recipient: string, message: string): Promise<boolean> {
|
async send(recipient: string, message: string): Promise<boolean> {
|
||||||
@@ -13,24 +15,43 @@ export class SmsNotificationStrategy implements NotificationStrategy {
|
|||||||
this.configService.get<string>("OZIKING_SMS_URL") ??
|
this.configService.get<string>("OZIKING_SMS_URL") ??
|
||||||
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms";
|
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms";
|
||||||
|
|
||||||
await axios.post(
|
const appKey = this.configService.get<string>("OZIKING_APP_KEY") ?? "";
|
||||||
url,
|
if (!appKey) {
|
||||||
{
|
this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API");
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
|
ScrollArea,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import type { ArrivalQueueItem } from "@/types/warehouse";
|
||||||
|
import { warehouseService } from "@/services/warehouse.service";
|
||||||
|
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
@@ -297,6 +300,13 @@ const LastMilePage = () => {
|
|||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [vehicleValue, setVehicleValue] = 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({
|
const { data: listData, isLoading } = useQuery({
|
||||||
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
||||||
queryFn: async () => {
|
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(
|
const activeRecord = useMemo(
|
||||||
() => records.find((r) => r.id === activeId) ?? null,
|
() => records.find((r) => r.id === activeId) ?? null,
|
||||||
[records, activeId],
|
[records, activeId],
|
||||||
@@ -633,7 +713,7 @@ const LastMilePage = () => {
|
|||||||
Assign vehicle ({selectedIds.length})
|
Assign vehicle ({selectedIds.length})
|
||||||
</Button>
|
</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
|
Assign Mile
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -688,6 +768,132 @@ const LastMilePage = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</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 */}
|
{/* Assign / Reassign modal */}
|
||||||
<Modal
|
<Modal
|
||||||
opened={assignOpen}
|
opened={assignOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user