diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 128df06fd..9514d8c5c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -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(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 : , }, + { + id: "invoice", + header: "Invoice", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; + if (!hasDistance) { + return ; + } + return ( + openInvoice(row.original)} + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + #345 + + ); + }, + }, { id: "status", header: "Status", @@ -767,9 +822,10 @@ const FirstMilePage = () => { View detail } onClick={() => openDistance(row.original.id)} > - Add Actual distance + Add distance {canPrint && ( { + + {/* Invoice modal */} + Invoice #345} + size="lg" + radius="lg" + centered + > + + {invoiceRecord && ( + <> + + + + EDR Freight + Invoice #345 + + + + + + + + + + + + + + + + + Post Payment + {formatPrice(invoiceRecord.remainingPayment)} + + + Advanced Payment + {formatPrice(invoiceRecord.advancedPayment)} + + + {(() => { + const postPayment = parseFloat(String(invoiceRecord.remainingPayment)); + const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment)); + const difference = postPayment - advancedPayment; + + if (difference > 0) { + return ( + + Remaining to Pay + {formatPrice(difference)} + + ); + } else if (difference < 0) { + return ( + + Refund + {formatPrice(Math.abs(difference))} + + ); + } else { + return ( + + Status + Settled + + ); + } + })()} + + + + + + )} + + + + + ); }; diff --git a/apps/edr-freight-web/backoffice/src/services/rates.service.ts b/apps/edr-freight-web/backoffice/src/services/rates.service.ts new file mode 100644 index 000000000..589f3ea13 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/rates.service.ts @@ -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(`/rates?pageSize=${pageSize}`), + getByType: (rateType: string) => + api.get(`/rates?rateType=${rateType}&pageSize=1000`), +}; diff --git a/packages/api-common/src/interceptors/response-transform.interceptor.ts b/packages/api-common/src/interceptors/response-transform.interceptor.ts index 402fcb600..9fc9b9a38 100644 --- a/packages/api-common/src/interceptors/response-transform.interceptor.ts +++ b/packages/api-common/src/interceptors/response-transform.interceptor.ts @@ -44,14 +44,19 @@ export class ResponseTransformInterceptor 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")){