common components extraction
This commit is contained in:
parent
7c8f847e5a
commit
e4da88b98b
6 changed files with 177 additions and 40 deletions
|
|
@ -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",
|
||||
|
|
|
|||
44
apps/pharmanager/src/components/ui/Checkbox.tsx
Normal file
44
apps/pharmanager/src/components/ui/Checkbox.tsx
Normal 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";
|
||||
99
apps/pharmanager/src/components/ui/Combobox.tsx
Normal file
99
apps/pharmanager/src/components/ui/Combobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue