mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 00:08:18 +00:00
feat(WIP): booking payment integration
This commit is contained in:
@@ -64,7 +64,6 @@ const App = () => {
|
|||||||
|
|
||||||
if (user && location.pathname === "/") navigate("/portal");
|
if (user && location.pathname === "/") navigate("/portal");
|
||||||
else if (!customer && !!isInProtectedRoutes) navigate("/onboarding");
|
else if (!customer && !!isInProtectedRoutes) navigate("/onboarding");
|
||||||
else if (customer && !isInProtectedRoutes) return navigate("/portal");
|
|
||||||
}, [user, location, customer]);
|
}, [user, location, customer]);
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
@@ -109,7 +108,10 @@ const App = () => {
|
|||||||
<Route path="/bookings/new" element={<NewBookingPage />} />
|
<Route path="/bookings/new" element={<NewBookingPage />} />
|
||||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
<Route path="/bookings/:id/contract" element={<BookingContractPage />} />
|
<Route
|
||||||
|
path="/bookings/:id/contract"
|
||||||
|
element={<BookingContractPage />}
|
||||||
|
/>
|
||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
|||||||
@@ -436,20 +436,6 @@ function DraftBookingView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pricingQuery.isError && (
|
|
||||||
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
|
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">Pricing failed</p>
|
|
||||||
<p className="mt-1 text-destructive/80">
|
|
||||||
{pricingQuery.error instanceof Error
|
|
||||||
? pricingQuery.error.message
|
|
||||||
: "An unexpected error occurred."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{uploadMutation.isError && (
|
{uploadMutation.isError && (
|
||||||
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
|
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||||
@@ -776,11 +762,28 @@ function DraftBookingView({
|
|||||||
|
|
||||||
function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const payMutation = useMutation({
|
||||||
|
mutationFn: () => api.bookings.pay.call({ id: booking.id }),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.redirectUrl) {
|
||||||
|
window.location.href = data.redirectUrl;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const normalizedStatus = booking.status as keyof typeof STATUS_MAP;
|
const normalizedStatus = booking.status as keyof typeof STATUS_MAP;
|
||||||
const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT;
|
const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT;
|
||||||
const currentStageIndex = statusConfig.stage;
|
const currentStageIndex = statusConfig.stage;
|
||||||
|
|
||||||
|
const pricing = booking.pricingBreakdown;
|
||||||
|
|
||||||
|
const uploadedCodes = useMemo(
|
||||||
|
() => new Set(booking.files?.map((f) => f.code) ?? []),
|
||||||
|
[booking.files],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-7xl px-4 py-8">
|
<div className="container mx-auto max-w-7xl px-4 py-8">
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
@@ -797,7 +800,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
||||||
<Package className="size-6" />
|
<Package className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
||||||
{booking.reference}
|
{booking.reference}
|
||||||
@@ -814,11 +817,100 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{normalizedStatus === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => payMutation.mutate()}
|
||||||
|
disabled={payMutation.isPending}
|
||||||
|
>
|
||||||
|
{payMutation.isPending ? (
|
||||||
|
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CreditCard className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{payMutation.isPending ? "Processing..." : "Pay Now"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{renderContractCard(booking, navigate)}
|
{renderContractCard(booking, navigate, payMutation)}
|
||||||
|
|
||||||
|
{pricing && (
|
||||||
|
<Card className="border-primary/20 bg-primary/5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<DollarSign className="size-4 text-primary" />
|
||||||
|
Pricing Breakdown
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead className="bg-muted text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-semibold">Description</th>
|
||||||
|
<th className="px-4 py-2 font-semibold text-right">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{pricing.lineItems.map((item, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-4 py-2 text-foreground">{item.description}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium text-foreground">
|
||||||
|
{item.amount.toLocaleString()} {item.currency}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="bg-primary/5 font-bold">
|
||||||
|
<td className="px-4 py-2 text-foreground">Total Estimated Cost</td>
|
||||||
|
<td className="px-4 py-2 text-right text-foreground">
|
||||||
|
{pricing.totalAmount.toLocaleString()} {pricing.currency}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{booking.files && booking.files.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileText className="size-4 text-primary" />
|
||||||
|
Uploaded Documents ({booking.files.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{booking.files.map((file) => (
|
||||||
|
<a
|
||||||
|
key={file.id}
|
||||||
|
href={file.signedUrl ?? file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border p-3 transition hover:border-primary/40"
|
||||||
|
>
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<FileText className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{file.code.replace(/_/g, " ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -1174,6 +1266,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
function renderContractCard(
|
function renderContractCard(
|
||||||
booking: Freight.IBooking,
|
booking: Freight.IBooking,
|
||||||
navigate: ReturnType<typeof useNavigate>,
|
navigate: ReturnType<typeof useNavigate>,
|
||||||
|
payMutation: { mutate: () => void; isPending: boolean },
|
||||||
) {
|
) {
|
||||||
const s = booking.status;
|
const s = booking.status;
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ export const api = {
|
|||||||
>("bookings", "uploadDocuments", ({ id, files }) =>
|
>("bookings", "uploadDocuments", ({ id, files }) =>
|
||||||
bookingsService.uploadDocuments(id, files),
|
bookingsService.uploadDocuments(id, files),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
pay: endpoint<{ id: string }, { redirectUrl: string }>(
|
||||||
|
"bookings",
|
||||||
|
"pay",
|
||||||
|
({ id }) => bookingsService.pay(id),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
consignments: {
|
consignments: {
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ export const bookingsService = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pay: async (id: string): Promise<{ redirectUrl: string }> => {
|
||||||
|
const { data } = await client.post(`/api/bookings/${id}/payment/pay`);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
signContract: async (
|
signContract: async (
|
||||||
id: string,
|
id: string,
|
||||||
payload: SignContractPayload,
|
payload: SignContractPayload,
|
||||||
|
|||||||
Reference in New Issue
Block a user