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/react-table": "^8.21.3",
|
||||||
"@tanstack/router-plugin": "^1.132.0",
|
"@tanstack/router-plugin": "^1.132.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "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 { Button, type ButtonProps, buttonVariants } from "./Button";
|
||||||
export { Input, type InputProps, inputVariants } from "./Input";
|
export { Input, type InputProps, inputVariants } from "./Input";
|
||||||
export { Textarea, type TextareaProps, textareaVariants } from "./Textarea";
|
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,
|
useListDistributors,
|
||||||
trpc,
|
trpc,
|
||||||
} from "shared-react";
|
} from "shared-react";
|
||||||
import { Button, Input, buttonVariants } from "#/components/ui";
|
import { Button, Input, Checkbox, buttonVariants } from "#/components/ui";
|
||||||
import { CreateProductInput } from "@repo/shared";
|
import { CreateProductInput } from "@repo/shared";
|
||||||
|
|
||||||
const formSchema = CreateProductInput.extend({
|
const formSchema = CreateProductInput.extend({
|
||||||
|
|
@ -361,23 +361,15 @@ function AddProductPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-full space-y-3">
|
<div className="col-span-full space-y-3">
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
<Checkbox
|
||||||
<input
|
{...register("hide_product_from_public")}
|
||||||
type="checkbox"
|
label="Hide Product from Public"
|
||||||
{...register("hide_product_from_public")}
|
/>
|
||||||
className="w-[18px] h-[18px] accent-blue-600 cursor-pointer"
|
<Checkbox
|
||||||
/>
|
{...register("hide_price_from_public")}
|
||||||
<span className="text-sm font-medium text-slate-900">Hide Product from Public</span>
|
disabled={hideProduct}
|
||||||
</label>
|
label="Hide Price from Public"
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
/>
|
||||||
<input
|
|
||||||
type="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"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-slate-900">Hide Price from Public</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-full flex justify-end gap-3 pt-5 mt-2 border-t border-slate-200">
|
<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,
|
useListDistributors,
|
||||||
trpc,
|
trpc,
|
||||||
} from "shared-react";
|
} from "shared-react";
|
||||||
import { Button, Input, buttonVariants } from "#/components/ui";
|
import { Button, Input, Checkbox, buttonVariants, Combobox } from "#/components/ui";
|
||||||
import { CreateStockBatchInput } from "@repo/shared";
|
import { CreateStockBatchInput } from "@repo/shared";
|
||||||
|
|
||||||
const formSchema = CreateStockBatchInput.extend({
|
const formSchema = CreateStockBatchInput.extend({
|
||||||
|
|
@ -53,6 +53,8 @@ function AddStockPage() {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
|
|
@ -69,6 +71,8 @@ function AddStockPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedProductId = watch("product_id");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditing && existingBatch) {
|
if (isEditing && existingBatch) {
|
||||||
reset({
|
reset({
|
||||||
|
|
@ -135,17 +139,16 @@ function AddStockPage() {
|
||||||
<label className="block text-sm font-medium text-slate-900 mb-1.5">
|
<label className="block text-sm font-medium text-slate-900 mb-1.5">
|
||||||
Product <span className="text-red-600">*</span>
|
Product <span className="text-red-600">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Combobox
|
||||||
{...register("product_id")}
|
options={(products ?? []).map((p) => ({
|
||||||
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"}`}
|
value: String(p.id),
|
||||||
>
|
label: `${p.name} — ${p.brand}`,
|
||||||
<option value={0}>Search and select a product...</option>
|
}))}
|
||||||
{(products ?? []).map((p) => (
|
value={selectedProductId ? String(selectedProductId) : ""}
|
||||||
<option key={p.id} value={p.id}>
|
onChange={(val) => setValue("product_id", Number(val))}
|
||||||
{p.name} — {p.brand}
|
placeholder="Search and select a product..."
|
||||||
</option>
|
emptyMessage="No products found."
|
||||||
))}
|
/>
|
||||||
</select>
|
|
||||||
{errors.product_id && <p className="text-sm text-red-600 mt-1">{errors.product_id.message}</p>}
|
{errors.product_id && <p className="text-sm text-red-600 mt-1">{errors.product_id.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -215,16 +218,10 @@ function AddStockPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-full">
|
<div className="col-span-full">
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
<Checkbox
|
||||||
<input
|
{...register("is_default")}
|
||||||
type="checkbox"
|
label="Set as default batch — stock will be deducted from this batch when billing"
|
||||||
{...register("is_default")}
|
/>
|
||||||
className="w-[18px] h-[18px] accent-blue-600 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<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>
|
||||||
|
|
||||||
<div className="col-span-full flex justify-end gap-3 pt-5 mt-2 border-t border-slate-200">
|
<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