mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge remote-tracking branch 'origin/freight/develop' into freight/feature/bootstrap_backoffice
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@edr/types": "workspace:*",
|
||||
"@edr/ui-common": "workspace:*",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tria-plc/iamui-common": "1.1.1",
|
||||
"axios": "^1.7.7",
|
||||
@@ -23,9 +24,11 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/services/api";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
export interface DynamicSelectProps {
|
||||
code: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DynamicSelect({
|
||||
code,
|
||||
placeholder,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
className,
|
||||
}: DynamicSelectProps) {
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.dropdownSettings.getByCode.queryOptions({ input: { code } }),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
Failed to load options
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const options = [...data.children].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={cn("w-full", className)}>
|
||||
<SelectValue placeholder={placeholder ?? `Select ${data.label}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.length === 0 ? (
|
||||
<div className="p-2 text-center text-sm text-muted-foreground">
|
||||
No options available
|
||||
</div>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
{option.note ? (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
{option.note}
|
||||
</span>
|
||||
) : null}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DynamicSelect } from "./DynamicSelect";
|
||||
export type { DynamicSelectProps } from "./DynamicSelect";
|
||||
@@ -14,14 +14,9 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import NewBookingPage from "./NewBookingPage";
|
||||
import DeleteBookingDialog from "./DeleteBookingDialog";
|
||||
import { getBookingById, type BookingStatus } from "./bookings.mock";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@edr/ui-common";
|
||||
import { Button, Card } from "@edr/ui-common";
|
||||
|
||||
export default function BookingDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -89,26 +84,7 @@ export default function BookingDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<NewBookingPage
|
||||
mode="edit"
|
||||
booking={{
|
||||
customerId: booking.customerId,
|
||||
cargoType: booking.cargoType,
|
||||
originStation: booking.originStation,
|
||||
destinationStation: booking.destinationStation,
|
||||
transportMode: booking.transportMode,
|
||||
legs: booking.legs,
|
||||
containerType: booking.containerType,
|
||||
containerCount: booking.containerCount,
|
||||
weightTons: booking.weightTons,
|
||||
requestedDate: booking.requestedDate,
|
||||
priority: booking.priority,
|
||||
cargoDescription: booking.cargoDescription,
|
||||
specialInstructions: booking.specialInstructions,
|
||||
}}
|
||||
>
|
||||
<Button>Edit Booking</Button>
|
||||
</NewBookingPage>
|
||||
|
||||
<DeleteBookingDialog
|
||||
bookingReference={booking.reference}
|
||||
@@ -142,7 +118,9 @@ export default function BookingDetailPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{booking.transportMode === "Multimodal" && booking.legs && booking.legs.length > 0 ? (
|
||||
{booking.transportMode === "Multimodal" &&
|
||||
booking.legs &&
|
||||
booking.legs.length > 0 ? (
|
||||
<Card className="p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Train />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
Clock,
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import NewBookingPage from "./NewBookingPage";
|
||||
import DeleteBookingDialog from "./DeleteBookingDialog";
|
||||
import { bookings, type BookingStatus } from "./bookings.mock";
|
||||
import {
|
||||
@@ -125,29 +124,6 @@ export default function BookingsPage() {
|
||||
<Eye />
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
<NewBookingPage
|
||||
mode="edit"
|
||||
booking={{
|
||||
customerId: booking.customerId,
|
||||
cargoType: booking.cargoType,
|
||||
originStation: booking.originStation,
|
||||
destinationStation: booking.destinationStation,
|
||||
transportMode: booking.transportMode,
|
||||
legs: booking.legs,
|
||||
containerType: booking.containerType,
|
||||
containerCount: booking.containerCount,
|
||||
weightTons: booking.weightTons,
|
||||
requestedDate: booking.requestedDate,
|
||||
priority: booking.priority,
|
||||
cargoDescription: booking.cargoDescription,
|
||||
specialInstructions: booking.specialInstructions,
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
</NewBookingPage>
|
||||
<DropdownMenuSeparator />
|
||||
<DeleteBookingDialog bookingReference={booking.reference}>
|
||||
<DropdownMenuItem
|
||||
@@ -191,12 +167,12 @@ export default function BookingsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewBookingPage>
|
||||
<Link to="/bookings/new">
|
||||
<Button>
|
||||
<Plus />
|
||||
New Booking
|
||||
</Button>
|
||||
</NewBookingPage>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
import { Fragment } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { STEPS } from "./schema";
|
||||
|
||||
export function StepIndicator({ step }: { step: number }) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{STEPS.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
<div className="flex shrink-0 flex-col items-center gap-1">
|
||||
<div
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors ${
|
||||
step > item.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: step === item.id
|
||||
? "border-2 border-primary text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{step > item.id ? <Check className="h-3.5 w-3.5" /> : item.id}
|
||||
</div>
|
||||
<span
|
||||
className={`hidden text-[10px] font-medium lg:block ${
|
||||
step >= item.id ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.short}
|
||||
</span>
|
||||
</div>
|
||||
{index < STEPS.length - 1 && (
|
||||
<div
|
||||
className={`mx-1 h-0.5 flex-1 rounded-full transition-colors ${
|
||||
step > item.id ? "bg-primary" : "bg-border"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
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>([
|
||||
"Addis Ababa",
|
||||
"Adama",
|
||||
"Mojo",
|
||||
"Awash",
|
||||
"Mieso",
|
||||
"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 = [
|
||||
"EDR-2024-10001",
|
||||
"EDR-2024-10002",
|
||||
"EDR-2023-88123",
|
||||
"EDR-2022-55442",
|
||||
];
|
||||
|
||||
export const REQUIRED_DOC_KEYS = [
|
||||
"tin_certificate",
|
||||
"business_license",
|
||||
"business_registration",
|
||||
"national_id",
|
||||
] as const;
|
||||
|
||||
export const STEPS = [
|
||||
{ id: 1, label: "Contract Type", short: "Contract" },
|
||||
{ id: 2, label: "Service Type", short: "Service" },
|
||||
{ id: 3, label: "First & Last Mile", short: "Mile" },
|
||||
{ id: 4, label: "Route", short: "Route" },
|
||||
{ id: 5, label: "Cargo Details", short: "Cargo" },
|
||||
{ id: 6, label: "Wagon Allocation", short: "Wagons" },
|
||||
{ id: 7, label: "Documents", short: "Docs" },
|
||||
{ id: 8, label: "Review & Submit", short: "Submit" },
|
||||
] as const;
|
||||
|
||||
export const BOOKING_DOCS_SETTING = {
|
||||
id: "booking-compliance",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deletedAt: null,
|
||||
code: "booking_compliance_docs",
|
||||
label: "Compliance Documents",
|
||||
description:
|
||||
"Upload your company's legal credentials. All mandatory documents must be submitted before the contract request can be reviewed by EDR Line Staff.",
|
||||
entity: "booking" as const,
|
||||
fields: [
|
||||
{
|
||||
id: "f1",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deletedAt: null,
|
||||
settingId: "booking-compliance",
|
||||
fileKey: "tin_certificate",
|
||||
fileLabel: "TIN Certificate",
|
||||
helpText:
|
||||
"Tax Identification Number certificate issued by ERCA (10-digit TIN).",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||
maxSizeMb: 5,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: "f2",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deletedAt: null,
|
||||
settingId: "booking-compliance",
|
||||
fileKey: "business_license",
|
||||
fileLabel: "Business / Investment License",
|
||||
helpText:
|
||||
"Current business or investment license issued by the relevant government authority.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||
maxSizeMb: 5,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: "f3",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deletedAt: null,
|
||||
settingId: "booking-compliance",
|
||||
fileKey: "business_registration",
|
||||
fileLabel: "Business Registration Certificate",
|
||||
helpText: "Certificate of registration from the relevant authority.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||
maxSizeMb: 5,
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: "f5",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deletedAt: null,
|
||||
settingId: "booking-compliance",
|
||||
fileKey: "power_of_attorney",
|
||||
fileLabel: "Power of Attorney (PoA)",
|
||||
helpText:
|
||||
"Required only if a representative is signing on behalf of the company.",
|
||||
isRequired: false,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||
maxSizeMb: 5,
|
||||
order: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const requiredString = (message: string) =>
|
||||
z.string().trim().min(1, { message });
|
||||
|
||||
const fileValueSchema = z.union([
|
||||
z.custom<File>(),
|
||||
z.array(z.custom<File>()),
|
||||
z.null(),
|
||||
]);
|
||||
|
||||
export const bookingFormSchema = z
|
||||
.object({
|
||||
contractType: z.enum(["new", "renewal", ""]),
|
||||
previousContractRef: z.string(),
|
||||
draftContractId: z.string(),
|
||||
serviceType: z.enum(["rail", "rail_forwarding", ""]),
|
||||
firstMileEnabled: z.boolean(),
|
||||
pickUpAddress: z.string(),
|
||||
lastMileEnabled: z.boolean(),
|
||||
deliveryAddress: z.string(),
|
||||
equipmentReturn: z.enum(["with_return", "without_return"]),
|
||||
originYard: z.string(),
|
||||
destinationYard: z.string(),
|
||||
cargoType: z.enum(["container", "bulk", ""]),
|
||||
cargoWeight: z.string(),
|
||||
freightType: z.enum(["bulk", "break_bulk", ""]),
|
||||
bulkCommodity: z.string(),
|
||||
bulkCommodityOther: z.string(),
|
||||
breakBulkType: z.string(),
|
||||
breakBulkTypeOther: z.string(),
|
||||
isHazardous: z.boolean(),
|
||||
isRefrigerated: z.boolean(),
|
||||
containers: z.array(
|
||||
z.object({
|
||||
type: z.enum(["20ft", "40ft"]),
|
||||
qty: z
|
||||
.string()
|
||||
.refine((q) => !isNaN(+q), "Enter a valid Number")
|
||||
.refine((qty) => Number(qty) >= 1, "Must be greater than 0"),
|
||||
vgm: z
|
||||
.string()
|
||||
.refine((vgm) => !isNaN(+vgm), "Enter a valid Number")
|
||||
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
|
||||
}),
|
||||
),
|
||||
consolidationEnabled: z.boolean(),
|
||||
documents: z.record(z.string(), fileValueSchema),
|
||||
notes: z.string(),
|
||||
termsAccepted: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.contractType) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["contractType"],
|
||||
message: "Select a contract type.",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.contractType === "new" && !data.draftContractId.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["draftContractId"],
|
||||
message: "A draft contract ID is required.",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.contractType === "renewal" && !data.previousContractRef.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["previousContractRef"],
|
||||
message: "Enter a previous contract reference.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.serviceType) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["serviceType"],
|
||||
message: "Select a service type.",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.firstMileEnabled && !data.pickUpAddress.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["pickUpAddress"],
|
||||
message: "Enter the pick-up address.",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.lastMileEnabled && !data.deliveryAddress.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["deliveryAddress"],
|
||||
message: "Enter the delivery address.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.originYard) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["originYard"],
|
||||
message: "Select an origin yard.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.destinationYard) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["destinationYard"],
|
||||
message: "Select a destination yard.",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
data.originYard &&
|
||||
data.destinationYard &&
|
||||
data.originYard === data.destinationYard
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["destinationYard"],
|
||||
message: "Destination must be different from origin.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.cargoType) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoType"],
|
||||
message: "Select a cargo type.",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.cargoType === "bulk") {
|
||||
if (!data.freightType) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["freightType"],
|
||||
message: "Select a freight type.",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.freightType === "bulk") {
|
||||
if (!data.bulkCommodity) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["bulkCommodity"],
|
||||
message: "Select a commodity.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.bulkCommodity === "Others" &&
|
||||
!data.bulkCommodityOther.trim()
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["bulkCommodityOther"],
|
||||
message: "Specify the commodity.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.freightType === "break_bulk") {
|
||||
if (!data.breakBulkType) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["breakBulkType"],
|
||||
message: "Select a break-bulk type.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.breakBulkType === "Others" &&
|
||||
!data.breakBulkTypeOther.trim()
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["breakBulkTypeOther"],
|
||||
message: "Specify the break-bulk type.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cargoWeight = Number(data.cargoWeight);
|
||||
if (!data.cargoWeight || Number.isNaN(cargoWeight) || cargoWeight <= 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoWeight"],
|
||||
message: "Enter a cargo weight greater than 0.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.cargoType === "container") {
|
||||
if (data.containers.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers"],
|
||||
message: "Add at least one container.",
|
||||
});
|
||||
}
|
||||
|
||||
data.containers.forEach((c, i) => {
|
||||
if (!c.qty || +c.qty < 1) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "qty"],
|
||||
message: "Enter at least 1 container.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!c.vgm || +c.vgm <= 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "vgm"],
|
||||
message: "Enter VGM greater than 0.",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of REQUIRED_DOC_KEYS) {
|
||||
const value = data.documents[key];
|
||||
const hasFile = Array.isArray(value) ? value.length > 0 : Boolean(value);
|
||||
if (!hasFile) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["documents", key],
|
||||
message: "Upload this required document.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.termsAccepted) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["termsAccepted"],
|
||||
message: "Accept the freight contract terms to submit.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
||||
|
||||
export const initialBookingFormValues: BookingFormValues = {
|
||||
contractType: "",
|
||||
previousContractRef: "",
|
||||
draftContractId: "",
|
||||
serviceType: "",
|
||||
firstMileEnabled: false,
|
||||
pickUpAddress: "",
|
||||
lastMileEnabled: false,
|
||||
deliveryAddress: "",
|
||||
equipmentReturn: "with_return",
|
||||
originYard: "",
|
||||
destinationYard: "",
|
||||
cargoType: "",
|
||||
cargoWeight: "",
|
||||
freightType: "",
|
||||
bulkCommodity: "",
|
||||
bulkCommodityOther: "",
|
||||
breakBulkType: "",
|
||||
breakBulkTypeOther: "",
|
||||
isHazardous: false,
|
||||
isRefrigerated: false,
|
||||
containers: [{ type: "20ft", qty: "1", vgm: "" }],
|
||||
consolidationEnabled: false,
|
||||
documents: {},
|
||||
notes: "",
|
||||
termsAccepted: false,
|
||||
};
|
||||
|
||||
export const stepFields: Record<number, Array<keyof BookingFormValues>> = {
|
||||
1: ["contractType", "previousContractRef", "draftContractId"],
|
||||
2: ["serviceType"],
|
||||
3: [
|
||||
"firstMileEnabled",
|
||||
"pickUpAddress",
|
||||
"lastMileEnabled",
|
||||
"deliveryAddress",
|
||||
"equipmentReturn",
|
||||
],
|
||||
4: ["originYard", "destinationYard", "isHazardous", "isRefrigerated"],
|
||||
5: [
|
||||
"cargoType",
|
||||
"cargoWeight",
|
||||
"freightType",
|
||||
"bulkCommodity",
|
||||
"bulkCommodityOther",
|
||||
"breakBulkType",
|
||||
"breakBulkTypeOther",
|
||||
"containers",
|
||||
],
|
||||
6: ["consolidationEnabled"],
|
||||
7: ["documents"],
|
||||
8: ["notes", "termsAccepted"],
|
||||
};
|
||||
|
||||
export type RouteDirection = "import" | "export" | "domestic" | null;
|
||||
|
||||
export interface ContainerConfig {
|
||||
type: "20ft" | "40ft";
|
||||
qty: string;
|
||||
vgm: string;
|
||||
}
|
||||
|
||||
export interface WagonConfig {
|
||||
type: "20ft" | "40ft";
|
||||
}
|
||||
|
||||
export interface WagonCalcResult {
|
||||
totalWagons: number;
|
||||
hasOddUnit: boolean;
|
||||
sharedWagons: number;
|
||||
wagonLayout: WagonConfig[];
|
||||
ft40Wagons: number;
|
||||
ft20Wagons: number;
|
||||
}
|
||||
|
||||
export function genContractId(): string {
|
||||
const yr = new Date().getFullYear();
|
||||
const n = Math.floor(10000 + Math.random() * 90000);
|
||||
return `EDR-DRAFT-${yr}-${n}`;
|
||||
}
|
||||
|
||||
export function getRouteDirection(
|
||||
origin: string,
|
||||
dest: string,
|
||||
): RouteDirection {
|
||||
if (!origin || !dest) return null;
|
||||
const oEth = ETHIOPIA_STATIONS.has(origin);
|
||||
const dEth = ETHIOPIA_STATIONS.has(dest);
|
||||
if (oEth && !dEth) return "export";
|
||||
if (!oEth && dEth) return "import";
|
||||
if (oEth && dEth) return "domestic";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
|
||||
const Ft40Wagons = containers
|
||||
.filter((c) => c.type === "40ft")
|
||||
.reduce((sum, c) => sum + Number(c.qty), 0);
|
||||
const Ft20Wagons = containers
|
||||
.filter((c) => c.type === "20ft")
|
||||
.reduce((sum, c) => sum + Number(c.qty), 0);
|
||||
const wagonLayout: WagonConfig[] = [];
|
||||
let hasOddUnit = Ft20Wagons % 2 === 1;
|
||||
let sharedWagons = Math.floor(Ft20Wagons / 2);
|
||||
|
||||
return {
|
||||
totalWagons: sharedWagons + Ft40Wagons,
|
||||
hasOddUnit,
|
||||
sharedWagons,
|
||||
ft40Wagons: Ft40Wagons,
|
||||
ft20Wagons: Ft20Wagons,
|
||||
wagonLayout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
ControllerRenderProps,
|
||||
FieldError as RhfFieldError,
|
||||
} from "react-hook-form";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@edr/ui-common";
|
||||
import type { BookingFormValues } from "./schema";
|
||||
import { REQUIRED_DOC_KEYS } from "./schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function getUploadedRequiredCount(
|
||||
documents: BookingFormValues["documents"],
|
||||
) {
|
||||
return REQUIRED_DOC_KEYS.filter((key) => {
|
||||
const file = documents[key];
|
||||
return Array.isArray(file) ? file.length > 0 : Boolean(file);
|
||||
}).length;
|
||||
}
|
||||
|
||||
export function OptionFieldError({ error }: { error?: { message?: string } }) {
|
||||
return <FieldError errors={[error]} />;
|
||||
}
|
||||
|
||||
export function OptionCard({
|
||||
selected,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`relative w-full rounded-xl border-2 p-4 text-left transition ${disabled
|
||||
? "cursor-not-allowed border-border bg-muted opacity-60"
|
||||
: selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-card hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
{selected && !disabled && (
|
||||
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-primary">
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertBox({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: "warning" | "error" | "success" | "info";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const styles = {
|
||||
warning: "bg-amber-50 border-amber-200 text-amber-800",
|
||||
error: "bg-red-50 border-red-200 text-red-800",
|
||||
success: "bg-emerald-50 border-emerald-200 text-emerald-800",
|
||||
info: "bg-sky-50 border-sky-200 text-sky-800",
|
||||
};
|
||||
const icons = {
|
||||
warning: <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />,
|
||||
error: <XCircle className="h-4 w-4 shrink-0 text-red-500" />,
|
||||
success: <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" />,
|
||||
info: <Info className="h-4 w-4 shrink-0 text-sky-500" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-3 rounded-xl border p-3 text-sm ${styles[tone]}`}
|
||||
>
|
||||
{icons[tone]}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepHeader({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
field,
|
||||
error,
|
||||
label,
|
||||
placeholder,
|
||||
children,
|
||||
}: {
|
||||
field: ControllerRenderProps<BookingFormValues>;
|
||||
error?: RhfFieldError;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Field data-invalid={Boolean(error)}>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<Select value={String(field.value)} onValueChange={field.onChange}>
|
||||
<SelectTrigger
|
||||
className={cn("w-full ", error ? "border-destructive!" : "")}
|
||||
aria-invalid={Boolean(error)}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>{children}</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[error]} />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectOptions({ options }: { options: readonly string[] }) {
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { FileText, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common";
|
||||
import { type BookingFormValues, genContractId } from "./schema";
|
||||
import { AlertBox, OptionCard, OptionFieldError, StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
export function Step1ContractType({
|
||||
form,
|
||||
renewalValid,
|
||||
renewalValidating,
|
||||
onValidate,
|
||||
}: {
|
||||
form: BookingForm;
|
||||
renewalValid: boolean | null;
|
||||
renewalValidating: boolean;
|
||||
onValidate: () => void;
|
||||
}) {
|
||||
const contractType = form.watch("contractType");
|
||||
const draftContractId = form.watch("draftContractId");
|
||||
const previousContractRef = form.watch("previousContractRef");
|
||||
const errors = form.formState.errors;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Contract Type"
|
||||
description="New contract or renewal of an existing one."
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="contractType"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={field.value === "new"}
|
||||
onClick={() => {
|
||||
field.onChange("new");
|
||||
form.clearErrors(["contractType", "previousContractRef"]);
|
||||
if (!draftContractId) {
|
||||
form.setValue("draftContractId", genContractId(), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<p className="font-semibold">New Contract</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Blank contract form. A draft ID is auto-generated.
|
||||
</p>
|
||||
{field.value === "new" && draftContractId && (
|
||||
<p className="mt-2 font-mono text-xs font-semibold text-primary">
|
||||
{draftContractId}
|
||||
</p>
|
||||
)}
|
||||
</OptionCard>
|
||||
|
||||
<OptionCard
|
||||
selected={field.value === "renewal"}
|
||||
onClick={() => {
|
||||
field.onChange("renewal");
|
||||
form.clearErrors("contractType");
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100">
|
||||
<RefreshCw className="h-4 w-4 text-sky-600" />
|
||||
</div>
|
||||
<p className="font-semibold">Contract Renewal</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Enter a previous reference to auto-populate historical
|
||||
parameters.
|
||||
</p>
|
||||
</OptionCard>
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
{contractType === "renewal" && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<Controller
|
||||
name="previousContractRef"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="previousContractRef">
|
||||
Previous Contract Reference Number
|
||||
</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
{...field}
|
||||
id="previousContractRef"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="e.g. EDR-2024-10001"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onValidate}
|
||||
disabled={!previousContractRef || renewalValidating}
|
||||
>
|
||||
{renewalValidating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Validate"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldError
|
||||
errors={[fieldState.error, errors.draftContractId]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{renewalValid === true && (
|
||||
<AlertBox tone="success">
|
||||
<strong>Contract found.</strong> Company details, route, and wagon
|
||||
preferences will be pre-filled.
|
||||
</AlertBox>
|
||||
)}
|
||||
{renewalValid === false && (
|
||||
<AlertBox tone="error">
|
||||
Contract Reference Number not found or unauthorized. Try{" "}
|
||||
<span className="font-mono">EDR-2024-10001</span>.
|
||||
</AlertBox>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Package, Train } from "lucide-react";
|
||||
import { Badge, Field, FieldError } from "@edr/ui-common";
|
||||
import { type BookingFormValues } from "./schema";
|
||||
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
export function Step2ServiceType({ form }: { form: BookingForm }) {
|
||||
const serviceType = form.watch("serviceType");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Service Type"
|
||||
description="Select the service combination you require."
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="serviceType"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={serviceType === "rail"}
|
||||
onClick={() => field.onChange("rail")}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100">
|
||||
<Train className="h-4 w-4 text-indigo-600" />
|
||||
</div>
|
||||
<p className="font-semibold">Rail Transport Only</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Rail transport along the EDR corridor, with optional
|
||||
first/last mile trucking.
|
||||
</p>
|
||||
<Badge className="mt-2 bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
|
||||
Option A
|
||||
</Badge>
|
||||
</OptionCard>
|
||||
|
||||
<OptionCard
|
||||
selected={serviceType === "rail_forwarding"}
|
||||
onClick={() => field.onChange("rail_forwarding")}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<p className="font-semibold">
|
||||
Rail Transport & Freight Forwarding
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Rail transport plus documentation, customs liaison, and a
|
||||
dedicated coordinator.
|
||||
</p>
|
||||
<Badge className="mt-2 bg-emerald-100 text-emerald-700 hover:bg-emerald-100">
|
||||
Option B
|
||||
</Badge>
|
||||
</OptionCard>
|
||||
</div>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Customs and Clearance Service cannot be selected independently. It must
|
||||
be bundled with a Rail Transport service.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Field, FieldError, Input, Switch } from "@edr/ui-common";
|
||||
import { type BookingFormValues } from "./schema";
|
||||
import { OptionCard, StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
export function Step3FirstLastMile({ form }: { form: BookingForm }) {
|
||||
const firstMileEnabled = form.watch("firstMileEnabled");
|
||||
const lastMileEnabled = form.watch("lastMileEnabled");
|
||||
const equipmentReturn = form.watch("equipmentReturn");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="First & Last Mile"
|
||||
description="Configure trucking and container return options."
|
||||
/>
|
||||
|
||||
<div className="divide-y divide-border rounded-xl border border-border">
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="firstMileEnabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">First Mile - Pick-up</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Truck pick-up from your premises to the origin rail yard.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("pickUpAddress", "", {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{firstMileEnabled && (
|
||||
<Controller
|
||||
name="pickUpAddress"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field className="mt-3" data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="Pick-up address *"
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="lastMileEnabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Last Mile - Delivery</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Truck delivery from the destination rail yard to the final
|
||||
address.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("deliveryAddress", "", {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{lastMileEnabled && (
|
||||
<Controller
|
||||
name="deliveryAddress"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field className="mt-3" data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="Delivery address *"
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<p className="mb-3 text-sm font-medium">Equipment Return</p>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Declare whether the container asset will be returned after
|
||||
unloading.
|
||||
</p>
|
||||
<Controller
|
||||
name="equipmentReturn"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={equipmentReturn === "with_return"}
|
||||
onClick={() => field.onChange("with_return")}
|
||||
>
|
||||
<p className="text-sm font-semibold">With Return</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Container returned to EDR after unloading.
|
||||
</p>
|
||||
</OptionCard>
|
||||
<OptionCard
|
||||
selected={equipmentReturn === "without_return"}
|
||||
onClick={() => field.onChange("without_return")}
|
||||
>
|
||||
<p className="text-sm font-semibold">Without Return</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Container retained by the customer after delivery.
|
||||
</p>
|
||||
</OptionCard>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||
import { Field, Separator, Switch } from "@edr/ui-common";
|
||||
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema";
|
||||
import { SelectField, SelectOptions, StepHeader, StepLabel } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
export function Step4Route({ form }: { form: BookingForm }) {
|
||||
const originYard = form.watch("originYard");
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
const direction = getRouteDirection(originYard, destinationYard);
|
||||
const directionStyle: Record<string, string> = {
|
||||
export: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
import: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
domestic: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
const directionLabel: Record<string, string> = {
|
||||
export: "Export workflow (Ethiopia to Djibouti)",
|
||||
import: "Import workflow (Djibouti to Ethiopia)",
|
||||
domestic: "Domestic corridor",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Route"
|
||||
description="Select the origin and destination yards."
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Controller
|
||||
name="originYard"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Origin Yard*"
|
||||
placeholder="Select origin..."
|
||||
>
|
||||
<SelectOptions
|
||||
options={STATIONS.filter((s) => s !== destinationYard)}
|
||||
/>
|
||||
</SelectField>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="destinationYard"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Destination Yard *"
|
||||
placeholder="Select destination..."
|
||||
>
|
||||
<SelectOptions
|
||||
options={STATIONS.filter((s) => s !== originYard)}
|
||||
/>
|
||||
</SelectField>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{direction && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
|
||||
>
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||
{directionLabel[direction]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-0 divide-y divide-border">
|
||||
<Controller
|
||||
name="isHazardous"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Flame className="h-4 w-4 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Hazardous Material</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Applies a Hazard Surcharge to the final bill.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="isRefrigerated"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Refrigerated Cargo</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Temperature-controlled transport applies a Refrigerator
|
||||
Surcharge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
Input,
|
||||
Separator,
|
||||
} from "@edr/ui-common";
|
||||
import {
|
||||
BREAK_BULK_TYPES,
|
||||
BULK_COMMODITIES,
|
||||
type BookingFormValues,
|
||||
type RouteDirection,
|
||||
} from "./schema";
|
||||
import {
|
||||
AlertBox,
|
||||
OptionCard,
|
||||
SelectField,
|
||||
SelectOptions,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
export function Step5CargoDetails({
|
||||
form,
|
||||
direction,
|
||||
}: {
|
||||
form: BookingForm;
|
||||
direction: RouteDirection;
|
||||
}) {
|
||||
const cargoType = form.watch("cargoType");
|
||||
const freightType = form.watch("freightType");
|
||||
const bulkCommodity = form.watch("bulkCommodity");
|
||||
const breakBulkType = form.watch("breakBulkType");
|
||||
const containers = form.watch("containers");
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "containers",
|
||||
});
|
||||
|
||||
function getOverweightAlert(
|
||||
type: "20ft" | "40ft",
|
||||
vgm: number,
|
||||
): string | null {
|
||||
if (type === "20ft" && vgm > 0) {
|
||||
const limit = direction === "export" ? 25 : 20;
|
||||
if (vgm > limit) {
|
||||
return `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`;
|
||||
}
|
||||
}
|
||||
if (type === "40ft" && vgm > 32.5) {
|
||||
return `VGM ${vgm}t exceeds the 32.5t global limit for a 40ft container. An Overweight Surcharge will apply.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Cargo Details"
|
||||
description="Define your cargo type, weight, and container configuration."
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<StepLabel>Cargo Type *</StepLabel>
|
||||
<Controller
|
||||
name="cargoType"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={cargoType === "container"}
|
||||
onClick={() => {
|
||||
field.onChange("container");
|
||||
form.setValue("freightType", "", { shouldDirty: true });
|
||||
form.setValue("cargoWeight", "", { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<p className="font-semibold">Container</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Pre-packed containerized cargo (20ft / 40ft).
|
||||
</p>
|
||||
</OptionCard>
|
||||
<OptionCard
|
||||
selected={cargoType === "bulk"}
|
||||
onClick={() => {
|
||||
field.onChange("bulk");
|
||||
form.setValue(
|
||||
"containers",
|
||||
[{ type: "20ft", qty: "1", vgm: "0" }],
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
|
||||
<Weight className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<p className="font-semibold">Bulk</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Bulk commodities or break-bulk cargo.
|
||||
</p>
|
||||
</OptionCard>
|
||||
</div>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cargoType === "bulk" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<StepLabel>Freight Type *</StepLabel>
|
||||
<Controller
|
||||
name="freightType"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={freightType === "bulk"}
|
||||
onClick={() => field.onChange("bulk")}
|
||||
>
|
||||
<p className="font-semibold">Bulk</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Coffee, fertilizer, grain, ore, etc.
|
||||
</p>
|
||||
</OptionCard>
|
||||
<OptionCard
|
||||
selected={freightType === "break_bulk"}
|
||||
onClick={() => field.onChange("break_bulk")}
|
||||
>
|
||||
<p className="font-semibold">Break-Bulk</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Machinery, vehicles, project cargo, etc.
|
||||
</p>
|
||||
</OptionCard>
|
||||
</div>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
{freightType === "bulk" && (
|
||||
<div className="space-y-2">
|
||||
<Controller
|
||||
name="bulkCommodity"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Commodity *"
|
||||
placeholder="Select commodity *"
|
||||
>
|
||||
<SelectOptions options={BULK_COMMODITIES} />
|
||||
</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 *"
|
||||
>
|
||||
<SelectOptions options={BREAK_BULK_TYPES} />
|
||||
</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>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<StepLabel>Weight</StepLabel>
|
||||
<Controller
|
||||
name="cargoWeight"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="cargoWeight">
|
||||
Total Cargo Weight - VGM (Tons) *
|
||||
</FieldLabel>
|
||||
<div className="relative">
|
||||
<Weight className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
{...field}
|
||||
id="cargoWeight"
|
||||
type="number"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="0.00"
|
||||
className="pl-9"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{cargoType === "container" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<StepLabel>Container Configuration</StepLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ type: "20ft", qty: "1", vgm: "0" })}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add Container
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{direction && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
Route detected as{" "}
|
||||
<span className="font-medium capitalize text-foreground">
|
||||
{direction}
|
||||
</span>{" "}
|
||||
workflow
|
||||
</p>
|
||||
)}
|
||||
|
||||
{fields.map((field, index) => {
|
||||
const containerType = containers[index]?.type;
|
||||
const vgm = containers[index]?.vgm ?? 0;
|
||||
const alert = getOverweightAlert(containerType, +vgm);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="space-y-3 rounded-xl border border-border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Container #{index + 1}
|
||||
</p>
|
||||
{fields.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
className="text-xs text-destructive hover:underline"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name={`containers.${index}.type`}
|
||||
control={form.control}
|
||||
render={({ field: typeField, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>Container Type *</FieldLabel>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{[
|
||||
{
|
||||
val: "20ft" as const,
|
||||
label: "20ft Container (TEU)",
|
||||
limit:
|
||||
direction === "export"
|
||||
? "Max 25t per container"
|
||||
: "Max 20t per container",
|
||||
},
|
||||
{
|
||||
val: "40ft" as const,
|
||||
label: "40ft Container (FEU)",
|
||||
limit: "Max 32.5t per container",
|
||||
},
|
||||
].map((ct) => (
|
||||
<OptionCard
|
||||
key={ct.val}
|
||||
selected={typeField.value === ct.val}
|
||||
onClick={() => typeField.onChange(ct.val)}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
<p className="font-semibold">{ct.label}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ct.limit}
|
||||
</p>
|
||||
</OptionCard>
|
||||
))}
|
||||
</div>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Controller
|
||||
name={`containers.${index}.qty`}
|
||||
control={form.control}
|
||||
render={({ field: qtyField, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>Quantity *</FieldLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
qtyField.onChange(
|
||||
Math.max(
|
||||
1,
|
||||
Number(qtyField.value ?? 1) - 1,
|
||||
).toString(),
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<Input
|
||||
value={qtyField.value ?? 1}
|
||||
onChange={(e) =>
|
||||
qtyField.onChange(e.target.value)
|
||||
}
|
||||
onBlur={qtyField.onBlur}
|
||||
type="number"
|
||||
aria-invalid={fieldState.invalid}
|
||||
className="text-center"
|
||||
min="1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
qtyField.onChange(
|
||||
(Number(qtyField.value ?? 1) + 1).toString(),
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name={`containers.${index}.vgm`}
|
||||
control={form.control}
|
||||
render={({ field: vgmField, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel>VGM (Tons) *</FieldLabel>
|
||||
<Input
|
||||
value={vgmField.value ?? 0}
|
||||
onChange={(e) => vgmField.onChange(e.target.value)}
|
||||
onBlur={vgmField.onBlur}
|
||||
type="number"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="e.g. 18.5"
|
||||
min="0"
|
||||
step="0.1"
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{alert && (
|
||||
<AlertBox tone="warning">
|
||||
<strong>Overweight Alert:</strong> {alert}
|
||||
</AlertBox>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { type UseFormReturn } from "react-hook-form";
|
||||
import { type BookingFormValues, type WagonCalcResult } from "./schema";
|
||||
import { AlertBox, StepHeader, StepLabel } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
export function Step6WagonAllocation({
|
||||
form,
|
||||
wagons,
|
||||
}: {
|
||||
form: BookingForm;
|
||||
wagons: WagonCalcResult | null;
|
||||
}) {
|
||||
const containers = form.watch("containers") ?? [];
|
||||
const totalContainers = containers.reduce(
|
||||
(sum, c) => sum + Number(c.qty || 0),
|
||||
0,
|
||||
);
|
||||
const containerSummary = containers
|
||||
.filter((c) => +c.qty > 0)
|
||||
.map((c) => `${c.qty} × ${c.type}`)
|
||||
.join(", ");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Wagon Allocation"
|
||||
description="System-calculated wagon requirements based on your container profile."
|
||||
/>
|
||||
|
||||
{!wagons ? (
|
||||
<AlertBox tone="info">
|
||||
Complete the container configuration in the previous step to see wagon
|
||||
allocation.
|
||||
</AlertBox>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl bg-primary/5 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-primary">
|
||||
{wagons.totalWagons}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Wagons Required
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted p-4 text-center">
|
||||
<p className="text-3xl font-bold">{totalContainers}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{containerSummary || "Containers"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted p-4 text-center">
|
||||
<p className="text-3xl font-bold">{wagons.sharedWagons}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Shared Slots</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<StepLabel>Wagon Layout</StepLabel>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{new Array(wagons.ft40Wagons).fill(0).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold
|
||||
border-primary/30 bg-primary/5 text-primary `}
|
||||
>
|
||||
1 × 40ft
|
||||
</div>
|
||||
))}
|
||||
{new Array(wagons.sharedWagons).fill(0).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold
|
||||
border-amber-300 bg-amber-50 text-amber-700
|
||||
`}
|
||||
>
|
||||
2 × 20ft
|
||||
</div>
|
||||
))}
|
||||
{wagons.hasOddUnit && (
|
||||
<div
|
||||
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold border-destructive! bg-destructive/10 text-destructive `}
|
||||
>
|
||||
1 × 20ft
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{wagons.hasOddUnit && (
|
||||
<>
|
||||
<AlertBox tone="warning">
|
||||
<div className="flex items-start gap-2">
|
||||
<div>
|
||||
<p className="font-semibold">Unpaired 20ft Container</p>
|
||||
<p className="mt-1 text-xs">
|
||||
One 20ft container occupies only half a wagon. The wagon
|
||||
will depart once a co-loader is found to fill the
|
||||
remaining slot, which <strong>may delay departure</strong>{" "}
|
||||
beyond the standard lead time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AlertBox>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Field, FieldError, SmartFileInput } from "@edr/ui-common";
|
||||
import {
|
||||
BOOKING_DOCS_SETTING,
|
||||
REQUIRED_DOC_KEYS,
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import { getUploadedRequiredCount, StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
export function Step7Documents({ form }: { form: BookingForm }) {
|
||||
const documents = form.watch("documents");
|
||||
const uploadedRequired = getUploadedRequiredCount(documents);
|
||||
const documentErrors = form.formState.errors.documents as
|
||||
| Record<string, { message?: string }>
|
||||
| undefined;
|
||||
const smartFileErrors = Object.fromEntries(
|
||||
Object.entries(documentErrors ?? {}).map(([key, value]) => [
|
||||
key,
|
||||
value?.message ?? "",
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Compliance Documents"
|
||||
description="Upload your company's legal credentials for EDR contract eligibility verification (US-04)."
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold ${uploadedRequired === REQUIRED_DOC_KEYS.length
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
{uploadedRequired}/{REQUIRED_DOC_KEYS.length}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{uploadedRequired < REQUIRED_DOC_KEYS.length
|
||||
? `${REQUIRED_DOC_KEYS.length - uploadedRequired} mandatory document(s) still needed.`
|
||||
: "All mandatory documents uploaded. Power of Attorney is optional."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="documents"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<SmartFileInput
|
||||
file={BOOKING_DOCS_SETTING}
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
errors={smartFileErrors}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Check } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
Textarea,
|
||||
} from "@edr/ui-common";
|
||||
import {
|
||||
REQUIRED_DOC_KEYS,
|
||||
type BookingFormValues,
|
||||
type RouteDirection,
|
||||
type WagonCalcResult,
|
||||
} from "./schema";
|
||||
import { getUploadedRequiredCount, StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
export function Step8Review({
|
||||
form,
|
||||
setStep,
|
||||
wagons,
|
||||
direction,
|
||||
}: {
|
||||
form: BookingForm;
|
||||
setStep: (step: number) => void;
|
||||
wagons: WagonCalcResult | null;
|
||||
direction: RouteDirection;
|
||||
}) {
|
||||
const values = form.watch();
|
||||
const errors = form.formState.errors;
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
target,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
target: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-0.5 truncate text-sm font-medium">{value || "-"}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(target)}
|
||||
className="shrink-0 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerSummary =
|
||||
values.cargoType === "container" && values.containers.length > 0
|
||||
? values.containers
|
||||
.filter((c) => c.qty > 0)
|
||||
.map((c) => `${c.qty} × ${c.type}`)
|
||||
.join(", ")
|
||||
: "";
|
||||
const totalVgm =
|
||||
values.cargoType === "container"
|
||||
? values.containers.reduce((sum, c) => sum + (c.qty || 0) * (c.vgm || 0), 0)
|
||||
: 0;
|
||||
|
||||
const cargoValue =
|
||||
values.cargoType === "container"
|
||||
? containerSummary
|
||||
: values.freightType === "bulk"
|
||||
? `Bulk - ${values.bulkCommodity === "Others" ? values.bulkCommodityOther : values.bulkCommodity}`
|
||||
: values.freightType === "break_bulk"
|
||||
? `Break-Bulk - ${values.breakBulkType === "Others" ? values.breakBulkTypeOther : values.breakBulkType}`
|
||||
: "";
|
||||
const uploadedCount = getUploadedRequiredCount(values.documents);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Review & Submit"
|
||||
description="Confirm your contract request before sending it for EDR staff review."
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="gap-0 overflow-hidden py-0">
|
||||
<CardHeader className="border-b px-5 py-3">
|
||||
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Contract & Service
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
|
||||
<Row
|
||||
label="Type"
|
||||
value={values.contractType === "new" ? "New Contract" : "Renewal"}
|
||||
target={1}
|
||||
/>
|
||||
<Row
|
||||
label="Contract ID"
|
||||
value={values.draftContractId || values.previousContractRef}
|
||||
target={1}
|
||||
/>
|
||||
<Row
|
||||
label="Service"
|
||||
value={
|
||||
values.serviceType === "rail"
|
||||
? "Rail Only"
|
||||
: values.serviceType === "rail_forwarding"
|
||||
? "Rail + Forwarding"
|
||||
: ""
|
||||
}
|
||||
target={2}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="gap-0 overflow-hidden py-0">
|
||||
<CardHeader className="border-b px-5 py-3">
|
||||
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
First & Last Mile
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
|
||||
<Row
|
||||
label="First Mile"
|
||||
value={
|
||||
values.firstMileEnabled ? values.pickUpAddress : "Not requested"
|
||||
}
|
||||
target={3}
|
||||
/>
|
||||
<Row
|
||||
label="Last Mile"
|
||||
value={
|
||||
values.lastMileEnabled
|
||||
? values.deliveryAddress
|
||||
: "Not requested"
|
||||
}
|
||||
target={3}
|
||||
/>
|
||||
<Row
|
||||
label="Equipment Return"
|
||||
value={
|
||||
values.equipmentReturn === "with_return"
|
||||
? "With Return"
|
||||
: "Without Return"
|
||||
}
|
||||
target={3}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="gap-0 overflow-hidden py-0">
|
||||
<CardHeader className="border-b px-5 py-3">
|
||||
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Route & Cargo
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
|
||||
<Row
|
||||
label="Route"
|
||||
value={`${values.originYard} -> ${values.destinationYard}`}
|
||||
target={4}
|
||||
/>
|
||||
<Row
|
||||
label="Workflow"
|
||||
value={
|
||||
direction
|
||||
? direction.charAt(0).toUpperCase() + direction.slice(1)
|
||||
: ""
|
||||
}
|
||||
target={4}
|
||||
/>
|
||||
<Row
|
||||
label="Weight (VGM)"
|
||||
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
|
||||
target={5}
|
||||
/>
|
||||
<Row label="Cargo" value={cargoValue} target={5} />
|
||||
<Row
|
||||
label="Modifiers"
|
||||
value={
|
||||
[
|
||||
values.isHazardous && "Hazardous",
|
||||
values.isRefrigerated && "Refrigerated",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "None"
|
||||
}
|
||||
target={4}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="gap-0 overflow-hidden py-0">
|
||||
<CardHeader className="border-b px-5 py-3">
|
||||
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Container & Wagons
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
|
||||
<Row
|
||||
label="Containers"
|
||||
value={containerSummary || "-"}
|
||||
target={5}
|
||||
/>
|
||||
<Row
|
||||
label="Total VGM"
|
||||
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
|
||||
target={5}
|
||||
/>
|
||||
<Row
|
||||
label="Wagons"
|
||||
value={
|
||||
wagons
|
||||
? `${wagons.totalWagons} wagon${wagons.totalWagons > 1 ? "s" : ""}`
|
||||
: ""
|
||||
}
|
||||
target={6}
|
||||
/>
|
||||
<Row
|
||||
label="Documents"
|
||||
value={`${uploadedCount}/${REQUIRED_DOC_KEYS.length} mandatory uploaded`}
|
||||
target={7}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="notes"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="notes">Additional Notes</FieldLabel>
|
||||
<Textarea
|
||||
{...field}
|
||||
id="notes"
|
||||
placeholder="Any special instructions or notes for EDR operations..."
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="termsAccepted"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={field.value}
|
||||
aria-invalid={fieldState.invalid}
|
||||
onClick={() => field.onChange(!field.value)}
|
||||
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 transition ${field.value ? "border-primary bg-primary" : "border-input"
|
||||
}`}
|
||||
>
|
||||
{field.value && (
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
I confirm the information is accurate and agree to EDR's{" "}
|
||||
<span className="text-primary">
|
||||
freight contract terms and conditions
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
<FieldError errors={[fieldState.error, errors.termsAccepted]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Step1ContractType } from "./step1-contract-type";
|
||||
export { Step2ServiceType } from "./step2-service-type";
|
||||
export { Step3FirstLastMile } from "./step3-first-last-mile";
|
||||
export { Step4Route } from "./step4-route";
|
||||
export { Step5CargoDetails } from "./step5-cargo-details";
|
||||
export { Step6WagonAllocation } from "./step6-wagon-allocation";
|
||||
export { Step7Documents } from "./step7-documents";
|
||||
export { Step8Review } from "./step8-review";
|
||||
@@ -12,6 +12,15 @@ import { bookingsService } from "./bookings.service";
|
||||
import { consignmentsService } from "./consignments.service";
|
||||
import { trackingService } from "./tracking.service";
|
||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||
import {
|
||||
CreateDropdownOptionDto,
|
||||
CreateDropdownSettingDto,
|
||||
DropdownOption,
|
||||
DropdownSetting,
|
||||
UpdateDropdownOptionDto,
|
||||
UpdateDropdownSettingDto,
|
||||
} from "@/types/dropdownSettings";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API definition
|
||||
@@ -135,4 +144,69 @@ export const api = {
|
||||
({ fieldId }) => fileUploadSettingsService.removeField(fieldId),
|
||||
),
|
||||
},
|
||||
|
||||
dropdownSettings: {
|
||||
list: endpoint<void, DropdownSetting[]>(
|
||||
"dropdown-settings",
|
||||
"list",
|
||||
dropdownSettingsService.list,
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, DropdownSetting>(
|
||||
"dropdown-settings",
|
||||
"getById",
|
||||
({ id }) => dropdownSettingsService.getById(id),
|
||||
),
|
||||
|
||||
getByCode: endpoint<{ code: string }, DropdownSetting>(
|
||||
"dropdown-settings",
|
||||
"getByCode",
|
||||
({ code }) => dropdownSettingsService.getByCode(code),
|
||||
),
|
||||
|
||||
create: endpoint<CreateDropdownSettingDto, DropdownSetting>(
|
||||
"dropdown-settings",
|
||||
"create",
|
||||
(payload) => dropdownSettingsService.create(payload),
|
||||
),
|
||||
|
||||
update: endpoint<
|
||||
{ id: string; dto: UpdateDropdownSettingDto },
|
||||
DropdownSetting
|
||||
>("dropdown-settings", "update", ({ id, dto }) =>
|
||||
dropdownSettingsService.update(id, dto),
|
||||
),
|
||||
|
||||
remove: endpoint<{ id: string }, void>(
|
||||
"dropdown-settings",
|
||||
"remove",
|
||||
({ id }) => dropdownSettingsService.remove(id),
|
||||
),
|
||||
replaceOptions: endpoint<
|
||||
{ id: string; options: CreateDropdownOptionDto[] },
|
||||
DropdownOption[]
|
||||
>("dropdown-settings", "replaceOptions", ({ id, options }) =>
|
||||
dropdownSettingsService.replaceOptions(id, options),
|
||||
),
|
||||
|
||||
addOption: endpoint<
|
||||
{ id: string; dto: CreateDropdownOptionDto },
|
||||
DropdownOption
|
||||
>("dropdown-settings", "addOption", ({ id, dto }) =>
|
||||
dropdownSettingsService.addOption(id, dto),
|
||||
),
|
||||
|
||||
updateOption: endpoint<
|
||||
{ optionId: string; dto: UpdateDropdownOptionDto },
|
||||
DropdownOption
|
||||
>("dropdown-settings", "updateOption", ({ optionId, dto }) =>
|
||||
dropdownSettingsService.updateOption(optionId, dto),
|
||||
),
|
||||
|
||||
removeOption: endpoint<{ optionId: string }, void>(
|
||||
"dropdown-settings",
|
||||
"removeOption",
|
||||
({ optionId }) => dropdownSettingsService.removeOption(optionId),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
"overrides": {
|
||||
"date-fns": "^3.6.0",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
246
packages/ui-common/src/components/field.tsx
Normal file
246
packages/ui-common/src/components/field.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
import { Label } from "./label"
|
||||
import { Separator } from "./separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
28
packages/ui-common/src/components/separator.tsx
Normal file
28
packages/ui-common/src/components/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
33
packages/ui-common/src/components/switch.tsx
Normal file
33
packages/ui-common/src/components/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { Switch as SwitchPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default" | "lg";
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 p-0.5 h-auto data-[size=lg]:w-10 data-[size=default]:w-8 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=lg]/switch:size-5 group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-7px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
@@ -30,3 +30,7 @@ export * from "./components/dropdown-menu";
|
||||
export * from "./components/data-table";
|
||||
export * from "./components/dialog";
|
||||
export * from "./components/SmartFileInput";
|
||||
export * from "./components/select";
|
||||
export * from "./components/switch";
|
||||
export * from "./components/separator";
|
||||
export * from "./components/field";
|
||||
|
||||
2515
pnpm-lock.yaml
generated
2515
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user