mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
feat(bookings): Introduce DRAFT booking workflow with comprehensive features for pricing, document management, submission, and cancellation.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
AlertCircle,
|
||||||
Calendar,
|
Calendar,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
@@ -24,21 +26,29 @@ import {
|
|||||||
FileSignature,
|
FileSignature,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
|
DollarSign,
|
||||||
|
Upload,
|
||||||
|
XCircle,
|
||||||
|
Building2,
|
||||||
|
FileUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import {
|
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
||||||
Card,
|
import {
|
||||||
CardHeader,
|
Card,
|
||||||
CardTitle,
|
CardHeader,
|
||||||
CardDescription,
|
CardTitle,
|
||||||
CardContent,
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
Badge,
|
Badge,
|
||||||
|
Button,
|
||||||
Separator,
|
Separator,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
|
||||||
const PROGRESS_STAGES = [
|
const PROGRESS_STAGES = [
|
||||||
{ label: "Request", icon: FileText, statuses: ["DRAFT"] },
|
{ label: "Request", icon: FileText, statuses: ["DRAFT"] },
|
||||||
@@ -55,9 +65,17 @@ const STATUS_MAP: Record<string, { title: string; description: string; color: st
|
|||||||
CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 },
|
CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REQUIRED_DOC_FIELDS = [
|
||||||
|
{ key: "commercial_invoice", label: "Commercial Invoice" },
|
||||||
|
{ key: "packing_list", label: "Packing List" },
|
||||||
|
{ key: "certificate_of_origin", label: "Certificate of Origin" },
|
||||||
|
{ key: "letter_of_credit", label: "Letter of Credit / LC" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function BookingDetailPage() {
|
export default function BookingDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: booking, isLoading, isError, error } = useQuery(
|
const { data: booking, isLoading, isError, error } = useQuery(
|
||||||
api.bookings.get.queryOptions({
|
api.bookings.get.queryOptions({
|
||||||
@@ -66,6 +84,10 @@ export default function BookingDetailPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const refetchBooking = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: id! }) });
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex items-center justify-center p-12">
|
<div className="container mx-auto flex items-center justify-center p-12">
|
||||||
@@ -110,17 +132,474 @@ export default function BookingDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (booking.status === "DRAFT") {
|
||||||
|
return <DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ReadonlyBookingView booking={booking} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraftBookingView({
|
||||||
|
booking,
|
||||||
|
onBookingUpdated,
|
||||||
|
}: {
|
||||||
|
booking: Freight.IBooking;
|
||||||
|
onBookingUpdated: () => void;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { customer } = useAuth();
|
||||||
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(null);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<Record<string, File | null>>({});
|
||||||
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
|
||||||
|
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
|
||||||
|
const allDocsProvided = !anyFileSelected;
|
||||||
|
|
||||||
|
const priceMutation = useMutation({
|
||||||
|
mutationFn: () => api.bookings.generatePrice.call({ id: booking.id }),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setPricingData(data);
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: booking.id }) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: (files: Record<string, File | File[] | null>) =>
|
||||||
|
api.bookings.uploadDocuments.call({ id: booking.id, files }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedFiles({});
|
||||||
|
onBookingUpdated();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitMutation = useMutation({
|
||||||
|
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
onBookingUpdated();
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: (reason: string) =>
|
||||||
|
api.bookings.cancel.call({ id: booking.id, reason }),
|
||||||
|
onSuccess: () => {
|
||||||
|
onBookingUpdated();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFileSelect(key: string, file: File | null) {
|
||||||
|
setSelectedFiles((prev) => ({ ...prev, [key]: file }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadAll() {
|
||||||
|
const filesToUpload: Record<string, File | null> = {};
|
||||||
|
for (const doc of REQUIRED_DOC_FIELDS) {
|
||||||
|
if (selectedFiles[doc.key]) {
|
||||||
|
filesToUpload[doc.key] = selectedFiles[doc.key]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(filesToUpload).length === 0) return;
|
||||||
|
uploadMutation.mutate(filesToUpload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
const reason = cancelReason.trim() || "Cancelled by customer";
|
||||||
|
cancelMutation.mutate(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canConfirm = !!pricingData && !uploadMutation.isPending && !submitMutation.isPending;
|
||||||
|
|
||||||
|
const companyName = (customer as any)?.company?.name ?? "—";
|
||||||
|
const companyTin = (customer as any)?.company?.tin ?? "—";
|
||||||
|
const contactName = (customer as any)?.profile
|
||||||
|
? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() || "—"
|
||||||
|
: "—";
|
||||||
|
const contactEmail = (customer as any)?.profile?.email ?? "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-5xl px-4 py-8">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Bookings", href: "/bookings" },
|
||||||
|
{ label: booking.reference },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-slate-200 text-slate-600">
|
||||||
|
<Package className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
||||||
|
{booking.reference}
|
||||||
|
</h1>
|
||||||
|
<StatusBadge status="DRAFT" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Complete the steps below to submit your booking request.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{priceMutation.isError && (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Pricing failed</p>
|
||||||
|
<p className="mt-1 text-red-600">
|
||||||
|
{priceMutation.error instanceof Error
|
||||||
|
? priceMutation.error.message
|
||||||
|
: "An unexpected error occurred."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadMutation.isError && (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Document upload failed</p>
|
||||||
|
<p className="mt-1 text-red-600">
|
||||||
|
{uploadMutation.error instanceof Error
|
||||||
|
? uploadMutation.error.message
|
||||||
|
: "An unexpected error occurred."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitMutation.isError && (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Submission failed</p>
|
||||||
|
<p className="mt-1 text-red-600">
|
||||||
|
{submitMutation.error instanceof Error
|
||||||
|
? submitMutation.error.message
|
||||||
|
: "An unexpected error occurred."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cancelMutation.isError && (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Cancel failed</p>
|
||||||
|
<p className="mt-1 text-red-600">
|
||||||
|
{cancelMutation.error instanceof Error
|
||||||
|
? cancelMutation.error.message
|
||||||
|
: "An unexpected error occurred."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className={cn(pricingData ? "border-emerald-200 bg-emerald-50/30" : "")}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<DollarSign className="size-4 text-primary" />
|
||||||
|
Pricing Estimation
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Generate a price estimate based on your booking details.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{pricingData ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<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">
|
||||||
|
{pricingData.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">
|
||||||
|
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pricingData.warnings.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||||
|
{pricingData.warnings.map((w, i) => (
|
||||||
|
<p key={i} className="flex items-start gap-2 text-xs text-amber-800">
|
||||||
|
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||||
|
{w}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs font-medium text-primary underline-offset-2 hover:underline"
|
||||||
|
onClick={() => {
|
||||||
|
setPricingData(null);
|
||||||
|
priceMutation.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Re-calculate pricing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||||
|
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<DollarSign className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">No pricing yet</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Generate a price estimate to review before submitting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => priceMutation.mutate()}
|
||||||
|
disabled={priceMutation.isPending}
|
||||||
|
>
|
||||||
|
{priceMutation.isPending ? (
|
||||||
|
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<DollarSign className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{priceMutation.isPending ? "Calculating..." : "Request Pricing Estimation"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileText className="size-4 text-primary" />
|
||||||
|
Required Documents
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Provide the necessary documents for this booking. Some information is
|
||||||
|
pre-filled from your company profile.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-6">
|
||||||
|
<div className="rounded-lg border border-primary/10 bg-primary/[0.02] p-4">
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-foreground">
|
||||||
|
<Building2 className="size-3.5 text-primary" />
|
||||||
|
Company Information (from profile)
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<InfoItem label="Company Name" value={companyName} />
|
||||||
|
<InfoItem label="TIN / VAT" value={companyTin} />
|
||||||
|
<InfoItem label="Contact Person" value={contactName} />
|
||||||
|
<InfoItem label="Contact Email" value={contactEmail} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[10px] text-muted-foreground">
|
||||||
|
To update your company information, go to{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-primary underline-offset-2 hover:underline"
|
||||||
|
onClick={() => navigate("/settings")}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-foreground">
|
||||||
|
<FileUp className="size-3.5 text-primary" />
|
||||||
|
Upload Booking Documents
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{REQUIRED_DOC_FIELDS.map((doc) => (
|
||||||
|
<div
|
||||||
|
key={doc.key}
|
||||||
|
className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<label className="text-xs font-medium text-foreground">
|
||||||
|
{doc.label}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={(el) => {
|
||||||
|
fileInputRefs.current[doc.key] = el;
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
handleFileSelect(doc.key, e.target.files?.[0] ?? null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||||
|
selectedFiles[doc.key]
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||||
|
: "border-border bg-background text-muted-foreground hover:border-foreground/20 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => fileInputRefs.current[doc.key]?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="size-3" />
|
||||||
|
{selectedFiles[doc.key] ? selectedFiles[doc.key]!.name : "Choose file"}
|
||||||
|
</button>
|
||||||
|
{selectedFiles[doc.key] && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-red-500"
|
||||||
|
onClick={() => handleFileSelect(doc.key, null)}
|
||||||
|
>
|
||||||
|
<XCircle className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUploadAll}
|
||||||
|
disabled={!anyFileSelected || uploadMutation.isPending}
|
||||||
|
>
|
||||||
|
{uploadMutation.isPending ? (
|
||||||
|
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{uploadMutation.isPending
|
||||||
|
? "Uploading..."
|
||||||
|
: anyFileSelected
|
||||||
|
? "Upload Selected Documents"
|
||||||
|
: "Select files to upload"}
|
||||||
|
</Button>
|
||||||
|
{uploadMutation.isSuccess && (
|
||||||
|
<p className="mt-2 flex items-center gap-1.5 text-xs text-emerald-600">
|
||||||
|
<CheckCircle2 className="size-3" />
|
||||||
|
Documents uploaded successfully
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-destructive/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base text-destructive">
|
||||||
|
<AlertTriangle className="size-4" />
|
||||||
|
Cancel Booking
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
If you no longer need this booking, you can cancel it.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Reason for cancellation (optional)"
|
||||||
|
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-xs text-foreground outline-none focus:border-destructive/50"
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={cancelMutation.isPending}
|
||||||
|
>
|
||||||
|
{cancelMutation.isPending ? (
|
||||||
|
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Cancel Booking
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 z-20 -mx-4 border-t border-border bg-background px-4 py-4">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-end gap-3">
|
||||||
|
{!canConfirm && (
|
||||||
|
<p className="mr-auto text-xs text-muted-foreground">
|
||||||
|
{!pricingData
|
||||||
|
? "Request pricing estimation before confirming."
|
||||||
|
: "Upload documents before confirming."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate("/bookings")}
|
||||||
|
>
|
||||||
|
Back to Bookings
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submitMutation.mutate()}
|
||||||
|
disabled={!canConfirm}
|
||||||
|
>
|
||||||
|
{submitMutation.isPending ? (
|
||||||
|
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{submitMutation.isPending
|
||||||
|
? "Submitting..."
|
||||||
|
: "Confirm Booking Request"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
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 containerCount = booking.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0;
|
|
||||||
const containerType = booking.containers?.[0]?.type ?? null;
|
|
||||||
|
|
||||||
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">
|
||||||
|
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: "Bookings", href: "/bookings" },
|
{ label: "Bookings", href: "/bookings" },
|
||||||
@@ -184,8 +663,8 @@ export default function BookingDetailPage() {
|
|||||||
<CardContent className="flex flex-col gap-8">
|
<CardContent className="flex flex-col gap-8">
|
||||||
<div className="relative flex w-full justify-between px-2">
|
<div className="relative flex w-full justify-between px-2">
|
||||||
<div className="absolute top-4 left-0 h-0.5 w-full bg-muted">
|
<div className="absolute top-4 left-0 h-0.5 w-full bg-muted">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all duration-500"
|
className="h-full bg-primary transition-all duration-500"
|
||||||
style={{ width: currentStageIndex >= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }}
|
style={{ width: currentStageIndex >= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,13 +672,13 @@ export default function BookingDetailPage() {
|
|||||||
{PROGRESS_STAGES.map((stage, idx) => {
|
{PROGRESS_STAGES.map((stage, idx) => {
|
||||||
const isCompleted = idx < currentStageIndex;
|
const isCompleted = idx < currentStageIndex;
|
||||||
const isActive = idx === currentStageIndex;
|
const isActive = idx === currentStageIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={stage.label} className="relative z-10 flex flex-col items-center gap-2">
|
<div key={stage.label} className="relative z-10 flex flex-col items-center gap-2">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex size-8 items-center justify-center rounded-full border-2 transition-all duration-300 bg-background",
|
"flex size-8 items-center justify-center rounded-full border-2 transition-all duration-300 bg-background",
|
||||||
isCompleted ? "border-primary text-primary" :
|
isCompleted ? "border-primary text-primary" :
|
||||||
isActive ? "border-primary text-primary shadow-[0_0_10px_rgba(16,185,129,0.3)] scale-110" :
|
isActive ? "border-primary text-primary shadow-[0_0_10px_rgba(16,185,129,0.3)] scale-110" :
|
||||||
"border-muted text-muted-foreground"
|
"border-muted text-muted-foreground"
|
||||||
)}>
|
)}>
|
||||||
{isCompleted ? <CheckCircle2 className="size-4" /> : <stage.icon className="size-4" />}
|
{isCompleted ? <CheckCircle2 className="size-4" /> : <stage.icon className="size-4" />}
|
||||||
@@ -435,14 +914,14 @@ function RouteEndpoint({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoItem({
|
function InfoItem({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
value
|
value
|
||||||
}: {
|
}: {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
value?: string | number | null
|
value?: string | number | null
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -465,8 +944,8 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn("px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]", statusColors[status] || "bg-muted")}
|
className={cn("px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]", statusColors[status] || "bg-muted")}
|
||||||
>
|
>
|
||||||
{status.replace(/_/g, ' ')}
|
{status.replace(/_/g, ' ')}
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Check,
|
Check,
|
||||||
CheckCircle2,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@edr/ui-common";
|
import { Button } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { Freight } from "@edr/types";
|
|
||||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
@@ -32,13 +30,11 @@ import {
|
|||||||
Step5CargoDetails,
|
Step5CargoDetails,
|
||||||
Step8Review,
|
Step8Review,
|
||||||
} from "./new-booking-form/steps";
|
} from "./new-booking-form/steps";
|
||||||
import useAuth from "@/hooks/useAuth";
|
|
||||||
|
|
||||||
export default function NewBookingPage() {
|
export default function NewBookingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const { customer } = useAuth();
|
|
||||||
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
||||||
api.bookings.referenceData.queryOptions(),
|
api.bookings.referenceData.queryOptions(),
|
||||||
);
|
);
|
||||||
@@ -48,7 +44,7 @@ export default function NewBookingPage() {
|
|||||||
api.bookings.create.call(payload),
|
api.bookings.create.call(payload),
|
||||||
onSuccess: (booking) => {
|
onSuccess: (booking) => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
setTimeout(() => navigate(`/bookings/${booking.id}`), 2500);
|
navigate(`/bookings/${booking.id}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,23 +128,27 @@ export default function NewBookingPage() {
|
|||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedChild =
|
||||||
|
data.cargoType !== "container" && data.bulkCommoditytype
|
||||||
|
? cargoTree
|
||||||
|
.find((g) => g.code.toLowerCase() === data.freightType)
|
||||||
|
?.children?.find((c) => c.name === data.bulkCommoditytype)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const cargoTypeId =
|
const cargoTypeId =
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? findContainerCargoTypeId()
|
? findContainerCargoTypeId()
|
||||||
: (findCargoTypeId(
|
: (findCargoTypeId(data.bulkCommoditytype) ??
|
||||||
data.freightType === "bulk"
|
cargoTree.find((g) => g.code.toLowerCase() === data.freightType)
|
||||||
? data.bulkCommodity
|
?.id ??
|
||||||
: data.breakBulkType,
|
"");
|
||||||
) ?? "");
|
|
||||||
|
|
||||||
const cargoFreeText =
|
const cargoFreeText =
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? undefined
|
? undefined
|
||||||
: data.freightType === "bulk" && data.bulkCommodity === "Others"
|
: selectedChild?.show_free_text_box
|
||||||
? data.bulkCommodityOther
|
? data.bulkCommoditytype
|
||||||
: data.freightType === "break_bulk" && data.breakBulkType === "Others"
|
: undefined;
|
||||||
? data.breakBulkTypeOther
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// ── Build API payload ───────────────────────────────────────────────
|
// ── Build API payload ───────────────────────────────────────────────
|
||||||
const apiPayload: CreateBookingPayload = {
|
const apiPayload: CreateBookingPayload = {
|
||||||
@@ -168,15 +168,16 @@ export default function NewBookingPage() {
|
|||||||
: direction === "domestic"
|
: direction === "domestic"
|
||||||
? "DOMESTIC"
|
? "DOMESTIC"
|
||||||
: "IMPORT",
|
: "IMPORT",
|
||||||
freightType:
|
|
||||||
data.cargoType === "container"
|
|
||||||
? Freight.FreightType.Container
|
|
||||||
: Freight.FreightType.Bulk,
|
|
||||||
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
paymentCurrency: "USD",
|
paymentCurrency: "USD",
|
||||||
allowConsolidation: data.consolidationEnabled,
|
allowConsolidation: data.consolidationEnabled,
|
||||||
|
// @ts-ignore
|
||||||
|
freightType:
|
||||||
|
data.cargoType === "container"
|
||||||
|
? ("CONTAINER" as const)
|
||||||
|
: ("BULK" as const),
|
||||||
containers:
|
containers:
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? data.containers.map((c) => ({
|
? data.containers.map((c) => ({
|
||||||
@@ -185,7 +186,6 @@ export default function NewBookingPage() {
|
|||||||
vgmPerUnitTons: Number(c.vgm || 0),
|
vgmPerUnitTons: Number(c.vgm || 0),
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
...(customer?.company?.id ? { companyId: customer.company.id } : {}),
|
|
||||||
...(data.previousContractRef
|
...(data.previousContractRef
|
||||||
? { previousContractId: data.previousContractRef }
|
? { previousContractId: data.previousContractRef }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -207,26 +207,6 @@ export default function NewBookingPage() {
|
|||||||
createMutation.mutate(apiPayload);
|
createMutation.mutate(apiPayload);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (createMutation.isSuccess) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center p-6">
|
|
||||||
<div className="w-full max-w-sm rounded-2xl border border-border bg-card p-10 text-center shadow-sm">
|
|
||||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
|
|
||||||
<CheckCircle2 className="h-7 w-7 text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold">Contract Submitted</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
Your request is queued for review by EDR Line Staff. You will be
|
|
||||||
notified once approved.
|
|
||||||
</p>
|
|
||||||
<p className="mt-4 font-mono text-sm font-semibold text-primary">
|
|
||||||
{createMutation.data?.reference}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
id="new-booking-form"
|
id="new-booking-form"
|
||||||
@@ -245,7 +225,7 @@ export default function NewBookingPage() {
|
|||||||
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Submission failed</p>
|
<p className="font-semibold">Failed to save draft</p>
|
||||||
<p className="mt-1 text-red-600">
|
<p className="mt-1 text-red-600">
|
||||||
{createMutation.error instanceof Error
|
{createMutation.error instanceof Error
|
||||||
? createMutation.error.message
|
? createMutation.error.message
|
||||||
@@ -306,9 +286,7 @@ export default function NewBookingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Check />
|
<Check />
|
||||||
)}
|
)}
|
||||||
{createMutation.isPending
|
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
|
||||||
? "Submitting..."
|
|
||||||
: "Submit Contract Request"}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,6 @@
|
|||||||
import { DeepPartial, Path } from "react-hook-form";
|
import { DeepPartial, Path } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
export const STATIONS = [
|
|
||||||
"Addis Ababa",
|
|
||||||
"Adama",
|
|
||||||
"Mojo",
|
|
||||||
"Awash",
|
|
||||||
"Mieso",
|
|
||||||
"Dire Dawa",
|
|
||||||
"Aysha",
|
|
||||||
"Ali Sabieh",
|
|
||||||
"Holhol",
|
|
||||||
"Djibouti City",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const ETHIOPIA_STATIONS = new Set<string>([
|
export const ETHIOPIA_STATIONS = new Set<string>([
|
||||||
"Addis Ababa",
|
"Addis Ababa",
|
||||||
"Adama",
|
"Adama",
|
||||||
@@ -23,19 +10,6 @@ export const ETHIOPIA_STATIONS = new Set<string>([
|
|||||||
"Dire Dawa",
|
"Dire Dawa",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const BULK_COMMODITIES = [
|
|
||||||
"Coffee",
|
|
||||||
"Beans",
|
|
||||||
"Fertilizer",
|
|
||||||
"Sugar",
|
|
||||||
"Oil",
|
|
||||||
"Livestock",
|
|
||||||
"Steel",
|
|
||||||
"Others",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const BREAK_BULK_TYPES = ["Machinery", "Ro-Ro", "Others"] as const;
|
|
||||||
|
|
||||||
export const MOCK_VALID_CONTRACTS = [
|
export const MOCK_VALID_CONTRACTS = [
|
||||||
"EDR-2024-10001",
|
"EDR-2024-10001",
|
||||||
"EDR-2024-10002",
|
"EDR-2024-10002",
|
||||||
@@ -43,31 +17,6 @@ export const MOCK_VALID_CONTRACTS = [
|
|||||||
"EDR-2022-55442",
|
"EDR-2022-55442",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CONTAINER_TYPES = [
|
|
||||||
"Dry Container",
|
|
||||||
"High Cubic",
|
|
||||||
"Reefer Container",
|
|
||||||
"Open Top",
|
|
||||||
"Flat Rack",
|
|
||||||
"Tank Container",
|
|
||||||
"Open Side",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const SHIPPING_LINES = [
|
|
||||||
"MSC",
|
|
||||||
"CMA CGM",
|
|
||||||
"Evergreen",
|
|
||||||
"COSCO",
|
|
||||||
"Hapag-Lloyd",
|
|
||||||
"ONE",
|
|
||||||
"Yang Ming",
|
|
||||||
"ZIM",
|
|
||||||
"Messina Line",
|
|
||||||
"Safmarine",
|
|
||||||
"Wan Hai",
|
|
||||||
"Ethiopian Shipping Lines (ESLSE)",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
{ id: 1, label: "Contract Type", short: "Contract" },
|
{ id: 1, label: "Contract Type", short: "Contract" },
|
||||||
{ id: 2, label: "Service Type & Mile", short: "Service" },
|
{ id: 2, label: "Service Type & Mile", short: "Service" },
|
||||||
@@ -108,11 +57,8 @@ export const bookingFormSchema = z
|
|||||||
shippingLine: z.string(),
|
shippingLine: z.string(),
|
||||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||||
cargoWeight: z.string(),
|
cargoWeight: z.string(),
|
||||||
freightType: z.enum(["bulk", "break_bulk"]).optional(),
|
freightType: z.string(), // parent group
|
||||||
bulkCommodity: z.string(),
|
bulkCommoditytype: z.string(),
|
||||||
bulkCommodityOther: z.string(),
|
|
||||||
breakBulkType: z.string(),
|
|
||||||
breakBulkTypeOther: z.string(),
|
|
||||||
isHazardous: z.boolean(),
|
isHazardous: z.boolean(),
|
||||||
isRefrigerated: z.boolean(),
|
isRefrigerated: z.boolean(),
|
||||||
containers: z.array(
|
containers: z.array(
|
||||||
@@ -171,42 +117,10 @@ export const bookingFormSchema = z
|
|||||||
(data) =>
|
(data) =>
|
||||||
!(
|
!(
|
||||||
data.cargoType === "bulk" &&
|
data.cargoType === "bulk" &&
|
||||||
data.freightType === "bulk" &&
|
data.freightType &&
|
||||||
!data.bulkCommodity
|
!data.bulkCommoditytype
|
||||||
),
|
),
|
||||||
{ message: "Select a commodity.", path: ["bulkCommodity"] },
|
{ message: "Select a commodity.", path: ["bulkCommoditytype"] },
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(data) =>
|
|
||||||
!(
|
|
||||||
data.cargoType === "bulk" &&
|
|
||||||
data.freightType === "bulk" &&
|
|
||||||
data.bulkCommodity === "Others" &&
|
|
||||||
!data.bulkCommodityOther.trim()
|
|
||||||
),
|
|
||||||
{ message: "Specify the commodity.", path: ["bulkCommodityOther"] },
|
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(data) =>
|
|
||||||
!(
|
|
||||||
data.cargoType === "bulk" &&
|
|
||||||
data.freightType === "break_bulk" &&
|
|
||||||
!data.breakBulkType
|
|
||||||
),
|
|
||||||
{ message: "Select a break-bulk type.", path: ["breakBulkType"] },
|
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(data) =>
|
|
||||||
!(
|
|
||||||
data.cargoType === "bulk" &&
|
|
||||||
data.freightType === "break_bulk" &&
|
|
||||||
data.breakBulkType === "Others" &&
|
|
||||||
!data.breakBulkTypeOther.trim()
|
|
||||||
),
|
|
||||||
{
|
|
||||||
message: "Specify the break-bulk type.",
|
|
||||||
path: ["breakBulkTypeOther"],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -268,10 +182,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
|||||||
destinationYard: "",
|
destinationYard: "",
|
||||||
shippingLine: "",
|
shippingLine: "",
|
||||||
cargoWeight: "",
|
cargoWeight: "",
|
||||||
bulkCommodity: "",
|
bulkCommoditytype: "",
|
||||||
bulkCommodityOther: "",
|
|
||||||
breakBulkType: "",
|
|
||||||
breakBulkTypeOther: "",
|
|
||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
isRefrigerated: false,
|
isRefrigerated: false,
|
||||||
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
||||||
@@ -300,10 +211,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
|||||||
"cargoType",
|
"cargoType",
|
||||||
"cargoWeight",
|
"cargoWeight",
|
||||||
"freightType",
|
"freightType",
|
||||||
"bulkCommodity",
|
"bulkCommoditytype",
|
||||||
"bulkCommodityOther",
|
|
||||||
"breakBulkType",
|
|
||||||
"breakBulkTypeOther",
|
|
||||||
"containers",
|
"containers",
|
||||||
"consolidationEnabled",
|
"consolidationEnabled",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ export function Step5CargoDetails({
|
|||||||
}) {
|
}) {
|
||||||
const cargoType = form.watch("cargoType");
|
const cargoType = form.watch("cargoType");
|
||||||
const freightType = form.watch("freightType");
|
const freightType = form.watch("freightType");
|
||||||
const bulkCommodity = form.watch("bulkCommodity");
|
const bulkCommoditytype = form.watch("bulkCommoditytype");
|
||||||
const breakBulkType = form.watch("breakBulkType");
|
|
||||||
const containers = form.watch("containers");
|
const containers = form.watch("containers");
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
@@ -60,13 +59,21 @@ export function Step5CargoDetails({
|
|||||||
);
|
);
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
const bulkCommodityOptions = useMemo(() => {
|
const freightTypeGroups = useMemo(() => {
|
||||||
if (!referenceData?.cargo_type) return [];
|
if (!referenceData?.cargo_type) return [];
|
||||||
return referenceData.cargo_type.flatMap(
|
return referenceData.cargo_type.filter(
|
||||||
(group) => group.children?.map((c) => c.name) ?? [],
|
(g) => g.code !== "CONTAINER",
|
||||||
);
|
);
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
|
const commodityOptions = useMemo(() => {
|
||||||
|
if (!referenceData?.cargo_type || !freightType) return [];
|
||||||
|
const group = referenceData.cargo_type.find(
|
||||||
|
(g) => g.code.toLowerCase() === freightType,
|
||||||
|
);
|
||||||
|
return group?.children?.map((c) => c.name) ?? [];
|
||||||
|
}, [referenceData, freightType]);
|
||||||
|
|
||||||
function getOverweightAlert(
|
function getOverweightAlert(
|
||||||
type: "20ft" | "40ft",
|
type: "20ft" | "40ft",
|
||||||
vgm: number,
|
vgm: number,
|
||||||
@@ -122,7 +129,7 @@ export function Step5CargoDetails({
|
|||||||
selected={cargoType === "container"}
|
selected={cargoType === "container"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
field.onChange("container");
|
field.onChange("container");
|
||||||
form.setValue("freightType", undefined, {
|
form.setValue("freightType", "", {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -194,82 +201,42 @@ export function Step5CargoDetails({
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field data-invalid={fieldState.invalid}>
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<OptionCard
|
{freightTypeGroups.map((group) => {
|
||||||
selected={freightType === "bulk"}
|
const val = group.code.toLowerCase();
|
||||||
onClick={() => field.onChange("bulk")}
|
return (
|
||||||
>
|
<OptionCard
|
||||||
<p className="font-semibold">Bulk</p>
|
key={group.code}
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
selected={freightType === val}
|
||||||
Coffee, fertilizer, grain, ore, etc.
|
onClick={() => {
|
||||||
</p>
|
field.onChange(val);
|
||||||
</OptionCard>
|
form.setValue("bulkCommoditytype", "", {
|
||||||
<OptionCard
|
shouldDirty: true,
|
||||||
selected={freightType === "break_bulk"}
|
});
|
||||||
onClick={() => field.onChange("break_bulk")}
|
}}
|
||||||
>
|
>
|
||||||
<p className="font-semibold">Break-Bulk</p>
|
<p className="font-semibold">{group.name}</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
</OptionCard>
|
||||||
Machinery, vehicles, project cargo, etc.
|
);
|
||||||
</p>
|
})}
|
||||||
</OptionCard>
|
|
||||||
</div>
|
</div>
|
||||||
<FieldError errors={[fieldState.error]} />
|
<FieldError errors={[fieldState.error]} />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{freightType === "bulk" && (
|
{freightType && commodityOptions.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Controller
|
<Controller
|
||||||
name="bulkCommodity"
|
name="bulkCommoditytype"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<SelectField
|
<SelectField
|
||||||
field={field}
|
field={field}
|
||||||
error={fieldState.error}
|
error={fieldState.error}
|
||||||
label="Commodity *"
|
label="Cargo type *"
|
||||||
placeholder="Select commodity *"
|
|
||||||
>
|
|
||||||
{bulkCommodityOptions.map((option) => (
|
|
||||||
<SelectItem key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectField>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{bulkCommodity === "Others" && (
|
|
||||||
<Controller
|
|
||||||
name="bulkCommodityOther"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
placeholder="Specify commodity *"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{freightType === "break_bulk" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Controller
|
|
||||||
name="breakBulkType"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<SelectField
|
|
||||||
field={field}
|
|
||||||
error={fieldState.error}
|
|
||||||
label="Break-bulk type *"
|
|
||||||
placeholder="Select type *"
|
placeholder="Select type *"
|
||||||
>
|
>
|
||||||
{bulkCommodityOptions.map((option) => (
|
{commodityOptions.map((option) => (
|
||||||
<SelectItem key={option} value={option}>
|
<SelectItem key={option} value={option}>
|
||||||
{option}
|
{option}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -277,22 +244,6 @@ export function Step5CargoDetails({
|
|||||||
</SelectField>
|
</SelectField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{breakBulkType === "Others" && (
|
|
||||||
<Controller
|
|
||||||
name="breakBulkTypeOther"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
placeholder="Specify break-bulk type *"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import type {
|
|||||||
UpdateFileUploadFieldDto,
|
UpdateFileUploadFieldDto,
|
||||||
UpdateFileUploadSettingDto,
|
UpdateFileUploadSettingDto,
|
||||||
} from "@/types/fileUploadSettings";
|
} from "@/types/fileUploadSettings";
|
||||||
import { bookingsService, CreateBookingPayload } from "./bookings.service";
|
import {
|
||||||
|
bookingsService,
|
||||||
|
CreateBookingPayload,
|
||||||
|
GeneratePriceResponse,
|
||||||
|
} from "./bookings.service";
|
||||||
import { consignmentsService } from "./consignments.service";
|
import { consignmentsService } from "./consignments.service";
|
||||||
import { trackingService } from "./tracking.service";
|
import { trackingService } from "./tracking.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
@@ -144,6 +148,31 @@ export const api = {
|
|||||||
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
||||||
bookingsService.remove(id),
|
bookingsService.remove(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
cancel: endpoint<{ id: string; reason: string }, Freight.IBooking>(
|
||||||
|
"bookings",
|
||||||
|
"cancel",
|
||||||
|
({ id, reason }) => bookingsService.cancel(id, reason),
|
||||||
|
),
|
||||||
|
|
||||||
|
generatePrice: endpoint<{ id: string }, GeneratePriceResponse>(
|
||||||
|
"bookings",
|
||||||
|
"generatePrice",
|
||||||
|
({ id }) => bookingsService.generatePrice(id),
|
||||||
|
),
|
||||||
|
|
||||||
|
submit: endpoint<{ id: string }, Freight.IBooking>(
|
||||||
|
"bookings",
|
||||||
|
"submit",
|
||||||
|
({ id }) => bookingsService.submit(id),
|
||||||
|
),
|
||||||
|
|
||||||
|
uploadDocuments: endpoint<
|
||||||
|
{ id: string; files: Record<string, File | File[] | null> },
|
||||||
|
Freight.IBooking
|
||||||
|
>("bookings", "uploadDocuments", ({ id, files }) =>
|
||||||
|
bookingsService.uploadDocuments(id, files),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
consignments: {
|
consignments: {
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ export interface ContractView {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PriceLineItem {
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratePriceResponse {
|
||||||
|
bookingId: string;
|
||||||
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
lineItems: PriceLineItem[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SignContractPayload {
|
export interface SignContractPayload {
|
||||||
role: "CUSTOMER" | "STAFF";
|
role: "CUSTOMER" | "STAFF";
|
||||||
signatureImageBase64: string;
|
signatureImageBase64: string;
|
||||||
@@ -53,6 +68,42 @@ export const bookingsService = {
|
|||||||
await client.delete(`/api/bookings/${id}`);
|
await client.delete(`/api/bookings/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cancel: async (id: string, reason: string): Promise<Freight.IBooking> => {
|
||||||
|
const { data } = await client.post(`/api/bookings/${id}/cancel`, { reason });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
generatePrice: async (id: string): Promise<GeneratePriceResponse> => {
|
||||||
|
const { data } = await client.post(`/api/bookings/${id}/generate-price`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
submit: async (id: string): Promise<Freight.IBooking> => {
|
||||||
|
const { data } = await client.post(`/api/bookings/${id}/submit`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadDocuments: async (
|
||||||
|
id: string,
|
||||||
|
files: Record<string, File | File[] | null>,
|
||||||
|
): Promise<Freight.IBooking> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const [key, fileOrFiles] of Object.entries(files)) {
|
||||||
|
if (!fileOrFiles) continue;
|
||||||
|
if (Array.isArray(fileOrFiles)) {
|
||||||
|
for (const f of fileOrFiles) formData.append(key, f);
|
||||||
|
} else {
|
||||||
|
formData.append(key, fileOrFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { data } = await client.post(
|
||||||
|
`/api/bookings/${id}/documents`,
|
||||||
|
formData,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
getContractView: async (id: string): Promise<ContractView> => {
|
getContractView: async (id: string): Promise<ContractView> => {
|
||||||
const { data } = await client.get(B.CONTRACT_VIEW(id));
|
const { data } = await client.get(B.CONTRACT_VIEW(id));
|
||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { api } from "../crud";
|
|
||||||
|
|
||||||
import { URL_CONSTANTS } from "../../constants/URLS"
|
|
||||||
Reference in New Issue
Block a user