mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +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,
|
MoreHorizontal,
|
||||||
Printer,
|
Printer,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Ruler,
|
||||||
Truck,
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
} from "@/services/first-mile.service";
|
} from "@/services/first-mile.service";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
|
import { ratesService } from "@/services/rates.service";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
|
||||||
const formatPrice = (amount: number) =>
|
const formatPrice = (amount: number) =>
|
||||||
@@ -330,6 +332,8 @@ const FirstMilePage = () => {
|
|||||||
|
|
||||||
const [distanceOpen, setDistanceOpen] = useState(false);
|
const [distanceOpen, setDistanceOpen] = useState(false);
|
||||||
const [distanceValue, setDistanceValue] = useState("");
|
const [distanceValue, setDistanceValue] = useState("");
|
||||||
|
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||||
|
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
|
||||||
|
|
||||||
const { data: listData, isLoading } = useQuery({
|
const { data: listData, isLoading } = useQuery({
|
||||||
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
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({
|
const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({
|
||||||
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
|
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
|
||||||
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
|
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
|
||||||
@@ -376,7 +388,7 @@ const FirstMilePage = () => {
|
|||||||
mutationFn: ({ id, data }: { id: string; data: { status?: FirstMileApiStatus; vehicleId?: string | null } }) =>
|
mutationFn: ({ id, data }: { id: string; data: { status?: FirstMileApiStatus; vehicleId?: string | null } }) =>
|
||||||
firstMileService.update(id, data),
|
firstMileService.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({ title: "Update failed", variant: "destructive" });
|
toast({ title: "Update failed", variant: "destructive" });
|
||||||
@@ -384,10 +396,10 @@ const FirstMilePage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateDistanceMutation = useMutation({
|
const updateDistanceMutation = useMutation({
|
||||||
mutationFn: ({ id, exactKm }: { id: string; exactKm: number }) =>
|
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
|
||||||
firstMileService.update(id, { exactKm }),
|
firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||||
if (activeRecord) {
|
if (activeRecord) {
|
||||||
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` });
|
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` });
|
||||||
}
|
}
|
||||||
@@ -485,13 +497,35 @@ const FirstMilePage = () => {
|
|||||||
setDistanceValue("");
|
setDistanceValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openInvoice = (record: FirstMileRecord) => {
|
||||||
|
setInvoiceRecord(record);
|
||||||
|
setInvoiceOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeInvoice = () => {
|
||||||
|
setInvoiceOpen(false);
|
||||||
|
setInvoiceRecord(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveDistance = () => {
|
const handleSaveDistance = () => {
|
||||||
const distance = parseFloat(distanceValue);
|
const distance = parseFloat(distanceValue);
|
||||||
if (!activeId || isNaN(distance) || distance < 0) {
|
if (!activeId || isNaN(distance) || distance < 0) {
|
||||||
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
|
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
|
||||||
return;
|
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) => {
|
const matchesFilter = (r: FirstMileRecord) => {
|
||||||
@@ -701,6 +735,27 @@ const FirstMilePage = () => {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
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",
|
id: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
@@ -767,9 +822,10 @@ const FirstMilePage = () => {
|
|||||||
View detail
|
View detail
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
leftSection={<Ruler size={15} />}
|
||||||
onClick={() => openDistance(row.original.id)}
|
onClick={() => openDistance(row.original.id)}
|
||||||
>
|
>
|
||||||
Add Actual distance
|
Add distance
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{canPrint && (
|
{canPrint && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -1122,6 +1178,87 @@ const FirstMilePage = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</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>
|
</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 (
|
if (
|
||||||
shouldFlatten &&
|
shouldFlatten &&
|
||||||
data &&
|
data &&
|
||||||
typeof data === "object" &&
|
typeof data === "object"
|
||||||
!Array.isArray(data)
|
|
||||||
) {
|
) {
|
||||||
return {
|
if(!Array.isArray(data)){
|
||||||
success: true,
|
|
||||||
...data,
|
return {
|
||||||
timestamp: new Date().toISOString(),
|
success: true,
|
||||||
};
|
...data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(path.startsWith("/api/positions/hierarchy") || path.startsWith("/api/positions/current")){
|
if(path.startsWith("/api/positions/hierarchy") || path.startsWith("/api/positions/current")){
|
||||||
|
|||||||
Reference in New Issue
Block a user