common components extraction

This commit is contained in:
shafi54 2026-05-23 16:30:47 +05:30
parent 7c8f847e5a
commit e4da88b98b
6 changed files with 177 additions and 40 deletions

View file

@ -23,6 +23,7 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.132.0",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.545.0",
"react": "19.1.0",
"react-dom": "19.1.0",

View file

@ -0,0 +1,44 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { cn } from "#/lib/cn";
export interface CheckboxProps extends ComponentPropsWithoutRef<"input"> {
label?: string;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ className, label, id, disabled, ...props }, ref) => {
return (
<label
className={cn(
"flex items-center gap-2.5 cursor-pointer",
disabled && "cursor-not-allowed",
)}
>
<input
ref={ref}
type="checkbox"
id={id}
disabled={disabled}
className={cn(
"w-[18px] h-[18px] accent-blue-600 cursor-pointer",
disabled && "opacity-40 cursor-not-allowed",
className,
)}
{...props}
/>
{label && (
<span
className={cn(
"text-sm font-medium text-slate-900",
disabled && "opacity-40",
)}
>
{label}
</span>
)}
</label>
);
},
);
Checkbox.displayName = "Checkbox";

View file

@ -0,0 +1,99 @@
import { useState, useRef, useEffect } from "react";
import { Command } from "cmdk";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "#/lib/cn";
export interface ComboboxOption {
value: string;
label: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
emptyMessage?: string;
className?: string;
}
export function Combobox({
options,
value,
onChange,
placeholder = "Search...",
emptyMessage = "No results found.",
className,
}: ComboboxProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const selectedLabel = options.find((o) => o.value === value)?.label ?? "";
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div ref={ref} className={cn("relative", className)}>
<button
type="button"
onClick={() => setOpen(!open)}
className={cn(
"w-full px-3.5 py-2.5 border border-slate-200 rounded-md text-sm text-left bg-white",
"focus:outline-none focus:ring-[3px] focus:ring-blue-100 focus:border-blue-600",
"flex items-center justify-between gap-2",
)}
>
<span className={selectedLabel ? "text-slate-900" : "text-slate-400"}>
{selectedLabel || placeholder}
</span>
<ChevronsUpDown className="w-4 h-4 text-slate-400 shrink-0" />
</button>
{open && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-slate-200 rounded-md shadow-lg overflow-hidden">
<Command>
<Command.Input
placeholder="Type to search..."
className="w-full px-3.5 py-2.5 border-b border-slate-200 text-sm outline-none placeholder:text-slate-400"
/>
<Command.List className="max-h-52 overflow-y-auto">
<Command.Empty className="px-4 py-8 text-sm text-center text-slate-500">
{emptyMessage}
</Command.Empty>
{options.map((option) => (
<Command.Item
key={option.value}
value={option.label}
onSelect={() => {
onChange(option.value);
setOpen(false);
}}
className={cn(
"flex items-center gap-2 px-3.5 py-2.5 text-sm cursor-pointer",
"data-[selected=true]:bg-blue-50 data-[selected=true]:text-blue-600",
)}
>
<Check
className={cn(
"w-4 h-4 shrink-0",
option.value === value ? "opacity-100" : "opacity-0",
)}
/>
{option.label}
</Command.Item>
))}
</Command.List>
</Command>
</div>
)}
</div>
);
}

View file

@ -1,3 +1,7 @@
export { Button, type ButtonProps, buttonVariants } from "./Button";
export { Input, type InputProps, inputVariants } from "./Input";
export { Textarea, type TextareaProps, textareaVariants } from "./Textarea";
export { Combobox } from "./Combobox";
export type { ComboboxOption } from "./Combobox";
export { Checkbox } from "./Checkbox";
export type { CheckboxProps } from "./Checkbox";

View file

@ -13,7 +13,7 @@ import {
useListDistributors,
trpc,
} from "shared-react";
import { Button, Input, buttonVariants } from "#/components/ui";
import { Button, Input, Checkbox, buttonVariants } from "#/components/ui";
import { CreateProductInput } from "@repo/shared";
const formSchema = CreateProductInput.extend({
@ -361,23 +361,15 @@ function AddProductPage() {
</div>
<div className="col-span-full space-y-3">
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
<Checkbox
{...register("hide_product_from_public")}
className="w-[18px] h-[18px] accent-blue-600 cursor-pointer"
label="Hide Product from Public"
/>
<span className="text-sm font-medium text-slate-900">Hide Product from Public</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
<Checkbox
{...register("hide_price_from_public")}
disabled={hideProduct}
className="w-[18px] h-[18px] accent-blue-600 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
label="Hide Price from Public"
/>
<span className="text-sm font-medium text-slate-900">Hide Price from Public</span>
</label>
</div>
<div className="col-span-full flex justify-end gap-3 pt-5 mt-2 border-t border-slate-200">

View file

@ -13,7 +13,7 @@ import {
useListDistributors,
trpc,
} from "shared-react";
import { Button, Input, buttonVariants } from "#/components/ui";
import { Button, Input, Checkbox, buttonVariants, Combobox } from "#/components/ui";
import { CreateStockBatchInput } from "@repo/shared";
const formSchema = CreateStockBatchInput.extend({
@ -53,6 +53,8 @@ function AddStockPage() {
register,
handleSubmit,
reset,
watch,
setValue,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
@ -69,6 +71,8 @@ function AddStockPage() {
},
});
const selectedProductId = watch("product_id");
useEffect(() => {
if (isEditing && existingBatch) {
reset({
@ -135,17 +139,16 @@ function AddStockPage() {
<label className="block text-sm font-medium text-slate-900 mb-1.5">
Product <span className="text-red-600">*</span>
</label>
<select
{...register("product_id")}
className={`w-full px-3.5 py-2.5 border rounded-md text-sm text-slate-900 bg-white focus:outline-none focus:ring-[3px] focus:ring-blue-100 ${errors.product_id ? "border-red-600" : "border-slate-200 focus:border-blue-600"}`}
>
<option value={0}>Search and select a product...</option>
{(products ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.brand}
</option>
))}
</select>
<Combobox
options={(products ?? []).map((p) => ({
value: String(p.id),
label: `${p.name}${p.brand}`,
}))}
value={selectedProductId ? String(selectedProductId) : ""}
onChange={(val) => setValue("product_id", Number(val))}
placeholder="Search and select a product..."
emptyMessage="No products found."
/>
{errors.product_id && <p className="text-sm text-red-600 mt-1">{errors.product_id.message}</p>}
</div>
@ -215,16 +218,10 @@ function AddStockPage() {
</div>
<div className="col-span-full">
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
<Checkbox
{...register("is_default")}
className="w-[18px] h-[18px] accent-blue-600 cursor-pointer"
label="Set as default batch — stock will be deducted from this batch when billing"
/>
<span className="text-sm font-medium text-slate-900">
Set as default batch stock will be deducted from this batch when billing
</span>
</label>
</div>
<div className="col-span-full flex justify-end gap-3 pt-5 mt-2 border-t border-slate-200">