common components extraction.

This commit is contained in:
shafi54 2026-05-23 16:39:23 +05:30
parent e4da88b98b
commit 3fd769a36f
12 changed files with 218 additions and 153 deletions

View file

@ -0,0 +1,19 @@
import { Link } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";
interface BackLinkProps {
to: string;
label: string;
}
export function BackLink({ to, label }: BackLinkProps) {
return (
<Link
to={to}
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:underline mb-5"
>
<ArrowLeft className="w-4 h-4" />
Back to {label}
</Link>
);
}

View file

@ -0,0 +1,29 @@
import { cn } from "#/lib/cn";
interface DetailRowProps {
label: string;
value: string;
valueClass?: string;
last?: boolean;
}
export function DetailRow({
label,
value,
valueClass = "",
last = false,
}: DetailRowProps) {
return (
<div
className={cn(
"flex justify-between items-center py-2.5 text-sm",
!last && "border-b border-slate-200",
)}
>
<span className="text-slate-600 text-[13px]">{label}</span>
<span className={cn("font-medium text-right", valueClass)}>
{value}
</span>
</div>
);
}

View file

@ -0,0 +1,38 @@
import { Link } from "@tanstack/react-router";
import { cn } from "#/lib/cn";
import { buttonVariants } from "./Button";
import type { LucideIcon } from "lucide-react";
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
actionLabel?: string;
actionTo?: string;
}
export function EmptyState({
icon: Icon,
title,
description,
actionLabel,
actionTo,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center py-16 px-6 text-slate-600">
<Icon className="w-12 h-12 mb-4 opacity-40" />
<h3 className="text-base font-semibold text-slate-900 mb-1.5">
{title}
</h3>
<p className="text-sm">{description}</p>
{actionLabel && actionTo && (
<Link
to={actionTo}
className={cn(buttonVariants({ variant: "primary" }), "mt-4")}
>
{actionLabel}
</Link>
)}
</div>
);
}

View file

@ -0,0 +1,29 @@
import type { ReactNode } from "react";
import { cn } from "#/lib/cn";
interface FormFieldProps {
label: string;
required?: boolean;
error?: string;
className?: string;
children: ReactNode;
}
export function FormField({
label,
required = false,
error,
className,
children,
}: FormFieldProps) {
return (
<div className={className}>
<label className="block text-sm font-medium text-slate-900 mb-1.5">
{label}
{required && <span className="text-red-600 ml-0.5">*</span>}
</label>
{children}
{error && <p className="text-sm text-red-600 mt-1">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,38 @@
import { Link } from "@tanstack/react-router";
import { Search, Plus } from "lucide-react";
import { buttonVariants } from "./Button";
interface SearchToolbarProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
addLink: string;
addLabel: string;
}
export function SearchToolbar({
value,
onChange,
placeholder,
addLink,
addLabel,
}: SearchToolbarProps) {
return (
<div className="flex items-center gap-3 mb-5 flex-wrap">
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-[480px] px-3.5 py-2 bg-white rounded-md border border-slate-200 transition-all duration-200 focus-within:border-blue-600 focus-within:shadow-[0_0_0_3px_rgba(37,99,235,0.1)]">
<Search className="w-4 h-4 text-slate-600 shrink-0" />
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-400"
/>
</div>
<Link to={addLink} className={buttonVariants({ variant: "primary" })}>
<Plus className="w-[15px] h-[15px]" />
{addLabel}
</Link>
</div>
);
}

View file

@ -1,7 +1,12 @@
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 { Checkbox } from "./Checkbox";
export type { CheckboxProps } from "./Checkbox"; export type { CheckboxProps } from "./Checkbox";
export { Combobox } from "./Combobox";
export type { ComboboxOption } from "./Combobox";
export { DetailRow } from "./DetailRow";
export { BackLink } from "./BackLink";
export { FormField } from "./FormField";
export { EmptyState } from "./EmptyState";
export { SearchToolbar } from "./SearchToolbar";

View file

@ -1,9 +1,9 @@
import { useState, useMemo, useCallback } from "react"; import { useState, useMemo, useCallback } from "react";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { Search, Plus, Pencil, Trash2, Truck } from "lucide-react"; import { Pencil, Trash2, Truck } from "lucide-react";
import { GridTable } from "#/components/GridTable"; import { GridTable } from "#/components/GridTable";
import type { GridTableColumn } from "#/components/GridTable"; import type { GridTableColumn } from "#/components/GridTable";
import { Button, buttonVariants } from "#/components/ui"; import { Button, SearchToolbar } from "#/components/ui";
import { import {
useListDistributors, useListDistributors,
useRemoveDistributor, useRemoveDistributor,
@ -149,25 +149,13 @@ function DistributorsIndexPage() {
return ( return (
<div> <div>
<div className="flex items-center gap-3 mb-5 flex-wrap"> <SearchToolbar
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-[400px] px-3.5 py-2 bg-white rounded-md border border-slate-200 transition-all duration-200 focus-within:border-blue-600 focus-within:shadow-[0_0_0_3px_rgba(37,99,235,0.1)]"> value={searchQuery}
<Search className="w-4 h-4 text-slate-600 shrink-0" /> onChange={setSearchQuery}
<input placeholder="Search distributors..."
type="text" addLink="/distributors/add"
value={searchQuery} addLabel="Add Distributor"
onChange={(e) => setSearchQuery(e.target.value)} />
placeholder="Search distributors..."
className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-400"
/>
</div>
<Link
to="/distributors/add"
className={buttonVariants({ variant: "primary" })}
>
<Plus className="w-[15px] h-[15px]" />
Add Distributor
</Link>
</div>
<GridTable <GridTable
columns={columns} columns={columns}

View file

@ -1,6 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, Pencil, Trash2, Pill, Package, Layers, DollarSign, EyeOff } from "lucide-react"; import { Pencil, Trash2, Pill, Package, Layers, DollarSign, EyeOff } from "lucide-react";
import { Button } from "#/components/ui"; import { Button, DetailRow, BackLink, EmptyState } from "#/components/ui";
import { useGetProductById, useRemoveProduct, trpc } from "shared-react"; import { useGetProductById, useRemoveProduct, trpc } from "shared-react";
export const Route = createFileRoute("/products/$id")({ export const Route = createFileRoute("/products/$id")({
@ -38,17 +38,13 @@ function ProductDetailsPage() {
if (error || !product) { if (error || !product) {
return ( return (
<div className="flex flex-col items-center py-16 px-6 text-slate-600"> <EmptyState
<Pill className="w-12 h-12 mb-4 opacity-40" /> icon={Pill}
<h3 className="text-base font-semibold text-slate-900 mb-1.5">Product not found</h3> title="Product not found"
<p className="text-sm mb-4">The product you're looking for doesn't exist.</p> description="The product you're looking for doesn't exist."
<Link actionLabel="Back to Products"
to="/products" actionTo="/products"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" />
>
Back to Products
</Link>
</div>
); );
} }
@ -64,16 +60,9 @@ function ProductDetailsPage() {
return ( return (
<div> <div>
<Link <BackLink to="/products" label="Products" />
to="/products"
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:underline mb-5"
>
<ArrowLeft className="w-4 h-4" />
Back to Products
</Link>
<div className="grid grid-cols-2 gap-6 max-w-[960px]"> <div className="grid grid-cols-2 gap-6 max-w-[960px]">
{/* Basic Info */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden"> <div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2"> <div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2">
<Pill className="w-[14px] h-[14px] text-slate-600" /> <Pill className="w-[14px] h-[14px] text-slate-600" />
@ -89,7 +78,6 @@ function ProductDetailsPage() {
</div> </div>
</div> </div>
{/* Inventory */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden"> <div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2"> <div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2">
<Package className="w-[14px] h-[14px] text-slate-600" /> <Package className="w-[14px] h-[14px] text-slate-600" />
@ -101,7 +89,6 @@ function ProductDetailsPage() {
</div> </div>
</div> </div>
{/* Composition */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden"> <div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2"> <div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2">
<Layers className="w-[14px] h-[14px] text-slate-600" /> <Layers className="w-[14px] h-[14px] text-slate-600" />
@ -126,7 +113,6 @@ function ProductDetailsPage() {
</div> </div>
</div> </div>
{/* Pricing */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden"> <div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2"> <div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2">
<DollarSign className="w-[14px] h-[14px] text-slate-600" /> <DollarSign className="w-[14px] h-[14px] text-slate-600" />
@ -140,7 +126,6 @@ function ProductDetailsPage() {
</div> </div>
</div> </div>
{/* Visibility */}
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden col-span-full"> <div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden col-span-full">
<div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2"> <div className="px-5 py-4 border-b border-slate-200 flex items-center gap-2">
<EyeOff className="w-[14px] h-[14px] text-slate-600" /> <EyeOff className="w-[14px] h-[14px] text-slate-600" />
@ -153,7 +138,6 @@ function ProductDetailsPage() {
</div> </div>
</div> </div>
{/* Actions */}
<div className="flex items-center gap-2.5 py-5 mt-2 max-w-[960px]"> <div className="flex items-center gap-2.5 py-5 mt-2 max-w-[960px]">
<Button variant="primary"> <Button variant="primary">
<Pencil className="w-[15px] h-[15px]" /> <Pencil className="w-[15px] h-[15px]" />
@ -167,22 +151,3 @@ function ProductDetailsPage() {
</div> </div>
); );
} }
function DetailRow({
label,
value,
valueClass = "",
last = false,
}: {
label: string;
value: string;
valueClass?: string;
last?: boolean;
}) {
return (
<div className={`flex justify-between items-center py-2.5 text-sm ${last ? "" : "border-b border-slate-200"}`}>
<span className="text-slate-600 text-[13px]">{label}</span>
<span className={`font-medium text-right ${valueClass}`}>{value}</span>
</div>
);
}

View file

@ -1,9 +1,9 @@
import { useState, useMemo, useCallback } from "react"; import { useState, useMemo, useCallback } from "react";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { Search, Plus, Pencil, Trash2, Pill } from "lucide-react"; import { Pencil, Trash2, Pill } from "lucide-react";
import { GridTable } from "#/components/GridTable"; import { GridTable } from "#/components/GridTable";
import type { GridTableColumn } from "#/components/GridTable"; import type { GridTableColumn } from "#/components/GridTable";
import { Button, buttonVariants } from "#/components/ui"; import { Button, SearchToolbar } from "#/components/ui";
import { useListProducts, useRemoveProduct, trpc } from "shared-react"; import { useListProducts, useRemoveProduct, trpc } from "shared-react";
interface ProductRow { interface ProductRow {
@ -200,25 +200,13 @@ function ProductsIndexPage() {
return ( return (
<div> <div>
<div className="flex items-center gap-3 mb-5 flex-wrap"> <SearchToolbar
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-[480px] px-3.5 py-2 bg-white rounded-md border border-slate-200 transition-all duration-200 focus-within:border-blue-600 focus-within:shadow-[0_0_0_3px_rgba(37,99,235,0.1)]"> value={searchQuery}
<Search className="w-4 h-4 text-slate-600 shrink-0" /> onChange={setSearchQuery}
<input placeholder="Search by product name, brand, or distributor..."
type="text" addLink="/products/add"
value={searchQuery} addLabel="Add Product"
onChange={(e) => setSearchQuery(e.target.value)} />
placeholder="Search by product name, brand, or distributor..."
className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-400"
/>
</div>
<Link
to="/products/add"
className={buttonVariants({ variant: "primary" })}
>
<Plus className="w-[15px] h-[15px]" />
Add Product
</Link>
</div>
<GridTable <GridTable
columns={columns} columns={columns}

View file

@ -1,6 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, Pencil, Trash2, Package, Calendar, MapPin, Truck } from "lucide-react"; import { Pencil, Trash2, Package, Calendar, MapPin, Truck } from "lucide-react";
import { Button } from "#/components/ui"; import { Button, DetailRow, BackLink, EmptyState } from "#/components/ui";
import { useGetStockBatchById, useRemoveStockBatch, trpc } from "shared-react"; import { useGetStockBatchById, useRemoveStockBatch, trpc } from "shared-react";
function daysUntil(expiry: string): number { function daysUntil(expiry: string): number {
@ -47,14 +47,13 @@ function StockDetailsPage() {
if (isLoading) return <div className="text-sm text-slate-600 py-8">Loading batch details...</div>; if (isLoading) return <div className="text-sm text-slate-600 py-8">Loading batch details...</div>;
if (error || !batch) { if (error || !batch) {
return ( return (
<div className="flex flex-col items-center py-16 px-6 text-slate-600"> <EmptyState
<Package className="w-12 h-12 mb-4 opacity-40" /> icon={Package}
<h3 className="text-base font-semibold text-slate-900 mb-1.5">Batch not found</h3> title="Batch not found"
<p className="text-sm mb-4">The batch you're looking for doesn't exist.</p> description="The batch you're looking for doesn't exist."
<Link to="/stock" className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"> actionLabel="Back to Stock"
Back to Stock actionTo="/stock"
</Link> />
</div>
); );
} }
@ -66,10 +65,7 @@ function StockDetailsPage() {
return ( return (
<div> <div>
<Link to="/stock" className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:underline mb-5"> <BackLink to="/stock" label="Stock" />
<ArrowLeft className="w-4 h-4" />
Back to Stock
</Link>
<div className="grid grid-cols-2 gap-6 max-w-[800px]"> <div className="grid grid-cols-2 gap-6 max-w-[800px]">
<div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden"> <div className="bg-white rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
@ -146,12 +142,3 @@ function StockDetailsPage() {
</div> </div>
); );
} }
function DetailRow({ label, value, valueClass = "", last = false }: { label: string; value: string; valueClass?: string; last?: boolean }) {
return (
<div className={`flex justify-between items-center py-2.5 text-sm ${last ? "" : "border-b border-slate-200"}`}>
<span className="text-slate-600 text-[13px]">{label}</span>
<span className={`font-medium text-right ${valueClass}`}>{value}</span>
</div>
);
}

View file

@ -1,9 +1,9 @@
import { useState, useMemo, useCallback } from "react"; import { useState, useMemo, useCallback } from "react";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { Search, Plus, Pencil, Trash2, Package, Star } from "lucide-react"; import { Pencil, Trash2, Package, Star } from "lucide-react";
import { GridTable } from "#/components/GridTable"; import { GridTable } from "#/components/GridTable";
import type { GridTableColumn } from "#/components/GridTable"; import type { GridTableColumn } from "#/components/GridTable";
import { Button, buttonVariants } from "#/components/ui"; import { Button, SearchToolbar } from "#/components/ui";
import { useListStockBatches, useRemoveStockBatch, trpc } from "shared-react"; import { useListStockBatches, useRemoveStockBatch, trpc } from "shared-react";
interface StockRow { interface StockRow {
@ -186,22 +186,13 @@ function StockIndexPage() {
return ( return (
<div> <div>
<div className="flex items-center gap-3 mb-5 flex-wrap"> <SearchToolbar
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-[480px] px-3.5 py-2 bg-white rounded-md border border-slate-200 transition-all duration-200 focus-within:border-blue-600 focus-within:shadow-[0_0_0_3px_rgba(37,99,235,0.1)]"> value={searchQuery}
<Search className="w-4 h-4 text-slate-600 shrink-0" /> onChange={setSearchQuery}
<input placeholder="Search by product, brand, batch, or rack..."
type="text" addLink="/stock/add"
value={searchQuery} addLabel="Add Stock"
onChange={(e) => setSearchQuery(e.target.value)} />
placeholder="Search by product, brand, batch, or rack..."
className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-400"
/>
</div>
<Link to="/stock/add" className={buttonVariants({ variant: "primary" })}>
<Plus className="w-[15px] h-[15px]" />
Add Stock
</Link>
</div>
<GridTable <GridTable
columns={columns} columns={columns}

View file

@ -1,9 +1,9 @@
import { useState, useMemo, useCallback } from "react"; import { useState, useMemo, useCallback } from "react";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { Search, Plus, Pencil, Trash2, Warehouse } from "lucide-react"; import { Pencil, Trash2, Warehouse } from "lucide-react";
import { GridTable } from "#/components/GridTable"; import { GridTable } from "#/components/GridTable";
import type { GridTableColumn } from "#/components/GridTable"; import type { GridTableColumn } from "#/components/GridTable";
import { Button, buttonVariants } from "#/components/ui"; import { Button, SearchToolbar } from "#/components/ui";
import { useListStorage, useRemoveStorage, trpc } from "shared-react"; import { useListStorage, useRemoveStorage, trpc } from "shared-react";
interface Rack { interface Rack {
@ -141,25 +141,13 @@ function StorageIndexPage() {
return ( return (
<div> <div>
<div className="flex items-center gap-3 mb-5 flex-wrap"> <SearchToolbar
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-[400px] px-3.5 py-2 bg-white rounded-md border border-slate-200 transition-all duration-200 focus-within:border-blue-600 focus-within:shadow-[0_0_0_3px_rgba(37,99,235,0.1)]"> value={searchQuery}
<Search className="w-4 h-4 text-slate-600 shrink-0" /> onChange={setSearchQuery}
<input placeholder="Search by name, alias, or description..."
type="text" addLink="/storage/add"
value={searchQuery} addLabel="Add Rack"
onChange={(e) => setSearchQuery(e.target.value)} />
placeholder="Search by name, alias, or description..."
className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-400"
/>
</div>
<Link
to="/storage/add"
className={buttonVariants({ variant: "primary" })}
>
<Plus className="w-[15px] h-[15px]" />
Add Rack
</Link>
</div>
<GridTable <GridTable
columns={columns} columns={columns}