Merge pull request #25 from Tria-plc/freight/feature/rebuild_booking_form

Freight/feature/rebuild booking form
This commit is contained in:
Nathnael Wondisha
2026-05-22 15:53:39 +03:00
committed by GitHub
14 changed files with 3804 additions and 2724 deletions

View File

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

View File

@@ -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>
<Button>Edit Booking</Button>
<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 />

View File

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

View File

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

View File

@@ -0,0 +1,491 @@
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: "f4",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "booking-compliance",
fileKey: "national_id",
fileLabel: "National ID / Passport",
helpText:
"Valid government-issued ID or passport of the authorized signatory.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 5,
order: 4,
},
{
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(),
containerType: z.enum(["20ft", "40ft", ""]),
quantity: z.string(),
vgm: z.string(),
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.containerType) {
ctx.addIssue({
code: "custom",
path: ["containerType"],
message: "Select a container type.",
});
}
const quantity = Number(data.quantity);
if (!data.quantity || !Number.isInteger(quantity) || quantity < 1) {
ctx.addIssue({
code: "custom",
path: ["quantity"],
message: "Enter at least 1 container.",
});
}
const vgm = Number(data.vgm);
if (!data.vgm || Number.isNaN(vgm) || vgm <= 0) {
ctx.addIssue({
code: "custom",
path: ["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,
containerType: "",
quantity: "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",
"containerType",
"quantity",
"vgm",
],
6: ["consolidationEnabled"],
7: ["documents"],
8: ["notes", "termsAccepted"],
};
export type RouteDirection = "import" | "export" | "domestic" | null;
export interface WagonCalcResult {
totalWagons: number;
hasOddUnit: boolean;
sharedWagons: 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(
type: BookingFormValues["containerType"],
qty: number,
): WagonCalcResult {
if (type === "40ft") {
return { totalWagons: qty, hasOddUnit: false, sharedWagons: qty };
}
const pairs = Math.floor(qty / 2);
const odd = qty % 2;
return { totalWagons: pairs + odd, hasOddUnit: odd > 0, sharedWagons: pairs };
}

View File

@@ -0,0 +1,157 @@
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 { cn } from "@/lib/utils";
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>
))}
</>
);
}
export function FormFieldDescription({ children }: { children: ReactNode }) {
return <FieldDescription>{children}</FieldDescription>;
}

File diff suppressed because it is too large Load Diff

View File

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

View 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,
}

View 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 }

View 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 };

View File

@@ -1,4 +1,4 @@
export { Table } from "./components/table";
export { Table } from "./components/table";
export type { TableProps, TableColumn } from "./components/Table/Table";
export { FormField } from "./components/Form";
@@ -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";

2674
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff