mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #327 from Tria-plc/freight/feature/vehicle_2
Freight/feature/vehicle 2
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
MoreHorizontal,
|
||||
Printer,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
} from "@/services/first-mile.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { ratesService } from "@/services/rates.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
const formatPrice = (amount: number) =>
|
||||
@@ -330,6 +332,8 @@ const FirstMilePage = () => {
|
||||
|
||||
const [distanceOpen, setDistanceOpen] = useState(false);
|
||||
const [distanceValue, setDistanceValue] = useState("");
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
|
||||
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
||||
@@ -347,6 +351,14 @@ const FirstMilePage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: ratesData } = useQuery({
|
||||
queryKey: ["rates", "FIRST_MILE"],
|
||||
queryFn: async () => {
|
||||
const res = await ratesService.getByType("FIRST_MILE");
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
|
||||
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
|
||||
@@ -376,7 +388,7 @@ const FirstMilePage = () => {
|
||||
mutationFn: ({ id, data }: { id: string; data: { status?: FirstMileApiStatus; vehicleId?: string | null } }) =>
|
||||
firstMileService.update(id, data),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Update failed", variant: "destructive" });
|
||||
@@ -384,10 +396,10 @@ const FirstMilePage = () => {
|
||||
});
|
||||
|
||||
const updateDistanceMutation = useMutation({
|
||||
mutationFn: ({ id, exactKm }: { id: string; exactKm: number }) =>
|
||||
firstMileService.update(id, { exactKm }),
|
||||
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
|
||||
firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||
if (activeRecord) {
|
||||
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` });
|
||||
}
|
||||
@@ -485,13 +497,35 @@ const FirstMilePage = () => {
|
||||
setDistanceValue("");
|
||||
};
|
||||
|
||||
const openInvoice = (record: FirstMileRecord) => {
|
||||
setInvoiceRecord(record);
|
||||
setInvoiceOpen(true);
|
||||
};
|
||||
|
||||
const closeInvoice = () => {
|
||||
setInvoiceOpen(false);
|
||||
setInvoiceRecord(null);
|
||||
};
|
||||
|
||||
const handleSaveDistance = () => {
|
||||
const distance = parseFloat(distanceValue);
|
||||
if (!activeId || isNaN(distance) || distance < 0) {
|
||||
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
updateDistanceMutation.mutate({ id: activeId, exactKm: distance });
|
||||
|
||||
let remainingPayment: number | undefined;
|
||||
if (ratesData?.data) {
|
||||
const firstMileRate = ratesData.data.find(
|
||||
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
|
||||
);
|
||||
if (firstMileRate) {
|
||||
const rateValue = parseFloat(firstMileRate.rateValue);
|
||||
remainingPayment = distance * rateValue;
|
||||
}
|
||||
}
|
||||
|
||||
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
|
||||
};
|
||||
|
||||
const matchesFilter = (r: FirstMileRecord) => {
|
||||
@@ -701,6 +735,27 @@ const FirstMilePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "invoice",
|
||||
header: "Invoice",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
|
||||
if (!hasDistance) {
|
||||
return <Text c="dimmed">—</Text>;
|
||||
}
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={() => openInvoice(row.original)}
|
||||
c="blue"
|
||||
fw={500}
|
||||
style={{ textDecoration: "underline", cursor: "pointer" }}
|
||||
>
|
||||
#345
|
||||
</UnstyledButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -767,9 +822,10 @@ const FirstMilePage = () => {
|
||||
View detail
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Ruler size={15} />}
|
||||
onClick={() => openDistance(row.original.id)}
|
||||
>
|
||||
Add Actual distance
|
||||
Add distance
|
||||
</Menu.Item>
|
||||
{canPrint && (
|
||||
<Menu.Item
|
||||
@@ -1122,6 +1178,87 @@ const FirstMilePage = () => {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Invoice modal */}
|
||||
<Modal
|
||||
opened={invoiceOpen}
|
||||
onClose={closeInvoice}
|
||||
title={<Text fw={600}>Invoice #345</Text>}
|
||||
size="lg"
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{invoiceRecord && (
|
||||
<>
|
||||
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>EDR Freight</Text>
|
||||
<Text fw={600} size="sm">Invoice #345</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
|
||||
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
|
||||
<InfoRow label="Pickup" value={pickupLocation(invoiceRecord)} />
|
||||
<InfoRow label="Destination" value={destinationYardName(invoiceRecord)} />
|
||||
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
|
||||
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
|
||||
</SimpleGrid>
|
||||
<Divider />
|
||||
<Group justify="flex-end">
|
||||
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
|
||||
<Group justify="space-between" w="100%">
|
||||
<Text size="sm" c="dimmed">Post Payment</Text>
|
||||
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" w="100%">
|
||||
<Text size="sm" c="dimmed">Advanced Payment</Text>
|
||||
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
|
||||
</Group>
|
||||
<Divider my="xs" />
|
||||
{(() => {
|
||||
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
|
||||
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
|
||||
const difference = postPayment - advancedPayment;
|
||||
|
||||
if (difference > 0) {
|
||||
return (
|
||||
<Group justify="space-between" w="100%">
|
||||
<Text size="sm" fw={600}>Remaining to Pay</Text>
|
||||
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
|
||||
</Group>
|
||||
);
|
||||
} else if (difference < 0) {
|
||||
return (
|
||||
<Group justify="space-between" w="100%">
|
||||
<Text size="sm" fw={600}>Refund</Text>
|
||||
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
|
||||
</Group>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Group justify="space-between" w="100%">
|
||||
<Text size="sm" fw={600}>Status</Text>
|
||||
<Text fw={700} c="blue">Settled</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeInvoice}>Close</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { api } from '../auth/http';
|
||||
|
||||
export interface Rate {
|
||||
id: string;
|
||||
rateType: string;
|
||||
appliesTo: string;
|
||||
trigger: string;
|
||||
containerTypeId: string | null;
|
||||
cargoTypeId: string | null;
|
||||
tradeDirection: string | null;
|
||||
currency: string;
|
||||
rateValue: string;
|
||||
rateUnit: string;
|
||||
status: string;
|
||||
proposedByStaffId: string;
|
||||
approvedByCeoId: string | null;
|
||||
approvedAt: string | null;
|
||||
effectiveFrom: string;
|
||||
effectiveTo: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RatesListResponse {
|
||||
data: Rate[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}
|
||||
|
||||
export const ratesService = {
|
||||
list: (pageSize = 1000) =>
|
||||
api.get<RatesListResponse>(`/rates?pageSize=${pageSize}`),
|
||||
getByType: (rateType: string) =>
|
||||
api.get<RatesListResponse>(`/rates?rateType=${rateType}&pageSize=1000`),
|
||||
};
|
||||
@@ -44,14 +44,19 @@ export class ResponseTransformInterceptor<T> implements NestInterceptor<
|
||||
if (
|
||||
shouldFlatten &&
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
!Array.isArray(data)
|
||||
typeof data === "object"
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
...data,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if(!Array.isArray(data)){
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...data,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
else {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
if(path.startsWith("/api/positions/hierarchy") || path.startsWith("/api/positions/current")){
|
||||
|
||||
Reference in New Issue
Block a user