feat(bookings): Introduce DRAFT booking workflow with comprehensive features for pricing, document management, submission, and cancellation.

This commit is contained in:
ghost2023
2026-06-05 09:50:09 +03:00
parent c5859dba6c
commit 0184db9040
7 changed files with 649 additions and 256 deletions

View File

@@ -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,11 +26,17 @@ 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 type { GeneratePriceResponse } from "@/services/bookings.service";
import { import {
Card, Card,
CardHeader, CardHeader,
@@ -36,9 +44,11 @@ import {
CardDescription, CardDescription,
CardContent, 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,13 +132,470 @@ 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">

View File

@@ -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>

View File

@@ -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",
], ],

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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;

View File

@@ -1,3 +0,0 @@
import { api } from "../crud";
import { URL_CONSTANTS } from "../../constants/URLS"