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 { 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 { 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 { 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 { useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import type { Freight } from "@/types";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
STEPS,
|
STEPS,
|
||||||
@@ -97,28 +111,55 @@ export default function NewBookingPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
const createAndPriceMutation = useMutation({
|
||||||
mutationFn: async (payload: CreateBookingPayload) => {
|
mutationFn: async (payload: CreateBookingPayload) => {
|
||||||
const booking = await api.bookings.create.call(payload);
|
const booking = await api.bookings.create.call(payload);
|
||||||
|
|
||||||
const documents = form.getValues("documents") ?? {};
|
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),
|
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||||
);
|
);
|
||||||
if (hasDocuments) {
|
if (hasDocs) {
|
||||||
await api.bookings.uploadDocuments.call({
|
await api.bookings.uploadDocuments.call({
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
files: documents,
|
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() });
|
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],
|
[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() {
|
async function handleContinue() {
|
||||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
@@ -265,10 +312,11 @@ export default function NewBookingPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleFullSubmit = form.handleSubmit((data) => {
|
const handleGeneratePrice = form.handleSubmit((data) => {
|
||||||
try {
|
try {
|
||||||
const apiPayload = buildApiPayload(data);
|
const apiPayload = buildApiPayload(data);
|
||||||
submitMutation.mutate(apiPayload);
|
setPricingPhase("generating");
|
||||||
|
createAndPriceMutation.mutate(apiPayload);
|
||||||
} catch {
|
} catch {
|
||||||
// validation error already handled
|
// validation error already handled
|
||||||
}
|
}
|
||||||
@@ -343,7 +391,7 @@ export default function NewBookingPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{submitMutation.isError && (
|
{createAndPriceMutation.isError && (
|
||||||
<Alert
|
<Alert
|
||||||
color="red"
|
color="red"
|
||||||
icon={<AlertCircle size={16} />}
|
icon={<AlertCircle size={16} />}
|
||||||
@@ -351,11 +399,11 @@ export default function NewBookingPage() {
|
|||||||
mb="lg"
|
mb="lg"
|
||||||
>
|
>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
Failed to submit
|
Failed to generate price estimate
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" mt={4} c="red.7">
|
<Text size="sm" mt={4} c="red.7">
|
||||||
{submitMutation.error instanceof Error
|
{createAndPriceMutation.error instanceof Error
|
||||||
? submitMutation.error.message
|
? createAndPriceMutation.error.message
|
||||||
: "An unexpected error occurred. Please try again."}
|
: "An unexpected error occurred. Please try again."}
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -392,6 +440,15 @@ export default function NewBookingPage() {
|
|||||||
setStep={setStep}
|
setStep={setStep}
|
||||||
direction={direction!}
|
direction={direction!}
|
||||||
referenceData={referenceData}
|
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>
|
</Box>
|
||||||
@@ -432,7 +489,7 @@ export default function NewBookingPage() {
|
|||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : pricingPhase === "idle" ? (
|
||||||
<Group>
|
<Group>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -452,21 +509,77 @@ export default function NewBookingPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
loading={submitMutation.isPending}
|
loading={createAndPriceMutation.isPending}
|
||||||
leftSection={
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
) : pricingPhase === "generating" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
loading
|
||||||
|
>
|
||||||
|
Generating price estimate…
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</form>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
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 {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
BOOKING_DOCS_SETTING,
|
BOOKING_DOCS_SETTING,
|
||||||
@@ -8,6 +20,7 @@ import {
|
|||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { StepHeader } from "./shared";
|
import { StepHeader } from "./shared";
|
||||||
import type { Freight } from "@/types";
|
import type { Freight } from "@/types";
|
||||||
|
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<
|
type BookingForm = UseFormReturn<
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
@@ -20,11 +33,25 @@ export function Step8Review({
|
|||||||
setStep,
|
setStep,
|
||||||
direction,
|
direction,
|
||||||
referenceData,
|
referenceData,
|
||||||
|
pricingPhase = "idle",
|
||||||
|
pricingData,
|
||||||
|
onConfirm,
|
||||||
|
onContinueLater,
|
||||||
|
onAbort,
|
||||||
|
confirmPending = false,
|
||||||
|
abortPending = false,
|
||||||
}: {
|
}: {
|
||||||
form: BookingForm;
|
form: BookingForm;
|
||||||
setStep: (step: number) => void;
|
setStep: (step: number) => void;
|
||||||
direction: Freight.ScheduleTradeDirection;
|
direction: Freight.ScheduleTradeDirection;
|
||||||
referenceData?: Freight.BookingReferenceData;
|
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 values = form.watch();
|
||||||
const errors = form.formState.errors;
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user