mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add the payment to booking page
This commit is contained in:
@@ -1,13 +1,27 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||
import type {
|
||||
CreateBookingPayload,
|
||||
GeneratePriceResponse,
|
||||
} from "@/services/bookings.service";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Alert, Box, Button, Group, Text, Title } from "@mantine/core";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, Check, ChevronLeft, ChevronRight, Send } from "lucide-react";
|
||||
import { AlertCircle, Check, ChevronLeft, ChevronRight, Send, XCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import type { Freight } from "@/types";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
STEPS,
|
||||
@@ -97,28 +111,55 @@ export default function NewBookingPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const submitMutation = useMutation({
|
||||
const createAndPriceMutation = useMutation({
|
||||
mutationFn: async (payload: CreateBookingPayload) => {
|
||||
const booking = await api.bookings.create.call(payload);
|
||||
|
||||
const documents = form.getValues("documents") ?? {};
|
||||
const hasDocuments = Object.values(documents).some((value) =>
|
||||
const hasDocs = Object.values(documents).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||
);
|
||||
if (hasDocuments) {
|
||||
if (hasDocs) {
|
||||
await api.bookings.uploadDocuments.call({
|
||||
id: booking.id,
|
||||
files: documents,
|
||||
});
|
||||
}
|
||||
|
||||
await api.bookings.submit.call({ id: booking.id });
|
||||
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
|
||||
|
||||
return booking;
|
||||
return { bookingId: booking.id, pricing };
|
||||
},
|
||||
onSuccess: (booking) => {
|
||||
onSuccess: ({ bookingId, pricing }) => {
|
||||
setPriceBookingId(bookingId);
|
||||
setPricingData(pricing);
|
||||
setPricingPhase("ready");
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
navigate(`/bookings/${booking.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
setPricingPhase("idle");
|
||||
},
|
||||
});
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!priceBookingId) throw new Error("No booking to confirm");
|
||||
await api.bookings.submit.call({ id: priceBookingId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
navigate(`/bookings/${priceBookingId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const abortMutation = useMutation({
|
||||
mutationFn: async (reason: string) => {
|
||||
if (!priceBookingId) throw new Error("No booking to abort");
|
||||
await api.bookings.cancel.call({ id: priceBookingId, reason });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setCancelDialogOpen(false);
|
||||
navigate("/bookings");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -150,6 +191,12 @@ export default function NewBookingPage() {
|
||||
[docValues],
|
||||
);
|
||||
|
||||
const [pricingPhase, setPricingPhase] = useState<"idle" | "generating" | "ready">("idle");
|
||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(null);
|
||||
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
|
||||
async function handleContinue() {
|
||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||
if (!valid) return;
|
||||
@@ -265,10 +312,11 @@ export default function NewBookingPage() {
|
||||
}
|
||||
});
|
||||
|
||||
const handleFullSubmit = form.handleSubmit((data) => {
|
||||
const handleGeneratePrice = form.handleSubmit((data) => {
|
||||
try {
|
||||
const apiPayload = buildApiPayload(data);
|
||||
submitMutation.mutate(apiPayload);
|
||||
setPricingPhase("generating");
|
||||
createAndPriceMutation.mutate(apiPayload);
|
||||
} catch {
|
||||
// validation error already handled
|
||||
}
|
||||
@@ -343,7 +391,7 @@ export default function NewBookingPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{submitMutation.isError && (
|
||||
{createAndPriceMutation.isError && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<AlertCircle size={16} />}
|
||||
@@ -351,11 +399,11 @@ export default function NewBookingPage() {
|
||||
mb="lg"
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
Failed to submit
|
||||
Failed to generate price estimate
|
||||
</Text>
|
||||
<Text size="sm" mt={4} c="red.7">
|
||||
{submitMutation.error instanceof Error
|
||||
? submitMutation.error.message
|
||||
{createAndPriceMutation.error instanceof Error
|
||||
? createAndPriceMutation.error.message
|
||||
: "An unexpected error occurred. Please try again."}
|
||||
</Text>
|
||||
</Alert>
|
||||
@@ -392,6 +440,15 @@ export default function NewBookingPage() {
|
||||
setStep={setStep}
|
||||
direction={direction!}
|
||||
referenceData={referenceData}
|
||||
pricingPhase={pricingPhase}
|
||||
pricingData={pricingData}
|
||||
onConfirm={() => confirmMutation.mutate()}
|
||||
onContinueLater={
|
||||
priceBookingId ? () => navigate(`/bookings/${priceBookingId}`) : undefined
|
||||
}
|
||||
onAbort={() => setCancelDialogOpen(true)}
|
||||
confirmPending={confirmMutation.isPending}
|
||||
abortPending={abortMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -432,7 +489,7 @@ export default function NewBookingPage() {
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
) : pricingPhase === "idle" ? (
|
||||
<Group>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -452,21 +509,77 @@ export default function NewBookingPage() {
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={submitMutation.isPending}
|
||||
loading={createAndPriceMutation.isPending}
|
||||
leftSection={
|
||||
submitMutation.isPending ? undefined : <Send size={16} />
|
||||
createAndPriceMutation.isPending ? undefined : <Send size={16} />
|
||||
}
|
||||
onClick={() => handleFullSubmit()}
|
||||
onClick={() => handleGeneratePrice()}
|
||||
>
|
||||
{submitMutation.isPending ? "Submitting..." : "Submit"}
|
||||
{createAndPriceMutation.isPending ? "Generating price…" : "Submit"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
) : pricingPhase === "generating" ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading
|
||||
>
|
||||
Generating price estimate…
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
{/* <DevTool control={form.control} /> */}
|
||||
|
||||
<Modal
|
||||
opened={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
title={<Text fw={700}>Abort booking</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Are you sure you want to abort this booking? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Reason for cancellation (optional)"
|
||||
placeholder="e.g. Change of plans…"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||
radius="md"
|
||||
data-autofocus
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setCancelDialogOpen(false)}
|
||||
>
|
||||
Keep editing
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
abortMutation.mutate(
|
||||
cancelReason.trim() || "Aborted by customer",
|
||||
)
|
||||
}
|
||||
disabled={abortMutation.isPending}
|
||||
loading={abortMutation.isPending}
|
||||
leftSection={
|
||||
!abortMutation.isPending ? <XCircle size={15} /> : undefined
|
||||
}
|
||||
>
|
||||
Yes, abort
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { Check, Send, XCircle } from "lucide-react";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
BOOKING_DOCS_SETTING,
|
||||
@@ -8,6 +20,7 @@ import {
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import type { Freight } from "@/types";
|
||||
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -20,11 +33,25 @@ export function Step8Review({
|
||||
setStep,
|
||||
direction,
|
||||
referenceData,
|
||||
pricingPhase = "idle",
|
||||
pricingData,
|
||||
onConfirm,
|
||||
onContinueLater,
|
||||
onAbort,
|
||||
confirmPending = false,
|
||||
abortPending = false,
|
||||
}: {
|
||||
form: BookingForm;
|
||||
setStep: (step: number) => void;
|
||||
direction: Freight.ScheduleTradeDirection;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
pricingPhase?: "idle" | "generating" | "ready";
|
||||
pricingData?: GeneratePriceResponse | null;
|
||||
onConfirm?: () => void;
|
||||
onContinueLater?: () => void;
|
||||
onAbort?: () => void;
|
||||
confirmPending?: boolean;
|
||||
abortPending?: boolean;
|
||||
}) {
|
||||
const values = form.watch();
|
||||
const errors = form.formState.errors;
|
||||
@@ -272,6 +299,75 @@ export function Step8Review({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{pricingPhase === "generating" && (
|
||||
<Card radius="lg" withBorder p="lg">
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Generating price estimate…
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{pricingPhase === "ready" && pricingData && (
|
||||
<Card radius="lg" withBorder p="lg">
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb="sm">
|
||||
Price Estimate
|
||||
</Text>
|
||||
{pricingData.lineItems.map((item) => (
|
||||
<Group key={item.code} justify="space-between" py={4}>
|
||||
<Text size="sm">{item.description}</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{item.amount.toLocaleString()} {item.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="sm" />
|
||||
<Group justify="space-between" py={2}>
|
||||
<Text fw={700}>Total</Text>
|
||||
<Text fw={700}>
|
||||
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
{pricingData.warnings.length > 0 && (
|
||||
<Text size="xs" c="orange.7" mt="sm">
|
||||
{pricingData.warnings.join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
<Group mt="lg">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={onConfirm}
|
||||
loading={confirmPending}
|
||||
>
|
||||
{confirmPending ? "Confirming…" : "Confirm"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={16} />}
|
||||
onClick={onContinueLater}
|
||||
>
|
||||
Continue later
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
|
||||
onClick={onAbort}
|
||||
loading={abortPending}
|
||||
>
|
||||
{abortPending ? "Aborting…" : "Abort"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user