Tuesday, March 18, 2025
React Query, Modern Modal Forms and Reusable Custom Data table
Posted by
Building a Modern Next.js Application with React Query , Modern Forms and Data table
This documentation will guide you through implementing a complete CRUD (Create, Read, Update, Delete) interface for a Product management module in a Next.js application. We'll use React Query for data fetching, Shadcn UI for components, Prisma ORM for database access, and implement advanced features like data tables with pagination, filtering, search, and Excel export.
Part 1: Project Setup
- Let's start by setting up a Next.js project with all the necessary dependencies.
Creating a New Next.js Project
- You can use any of your existing Next js Project or you can get your self a full featured next js starter kit
Part 2: Database Schema and Prisma Configuration
- First, let's set up Prisma with our Product model.
Schema Definition
- Create or update your prisma/schema.prisma file with the following content:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite" // You can change this to postgresql, mysql, etc.
url = env("DATABASE_URL")
}
model Product {
id String @id @default(cuid())
name String
price String
numberPlate String @unique
sales Sale[]
salesCount Int @default(0)
salesTotal Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// We'll define a simple Sale model for reference
model Sale {
id String @id @default(cuid())
product Product @relation(fields: [productId], references: [id])
productId String
quantity Int
amount Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Part 3: Server Actions Implementation
- Let's create server actions for our Product CRUD operations.
Types Definition
First, let's define the types:
// types/product.ts
export interface Product {
id: string;
name: string;
price: string;
numberPlate: string;
salesCount: number;
salesTotal: number;
createdAt: Date | string;
updatedAt: Date | string;
}
export type ProductPayload = {
name: string;
price: string;
numberPlate: string;
};
export type UpdateProductPayload = {
name?: string;
price?: string;
numberPlate?: string;
};
export type ProductApiResponse = {
success: boolean;
data?: any;
error?: string;
};
Server Actions
Now, let's implement the server actions for our Product CRUD operations:
// actions/products.ts
"use server";
import { db } from "@/prisma/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import type {
ProductPayload,
UpdateProductPayload,
ProductApiResponse,
} from "@/types/product";
// Schema for product validation
const productSchema = z.object({
name: z.string().min(1, "Name is required"),
price: z.string().min(1, "Price is required"),
numberPlate: z.string().min(1, "Number plate is required"),
});
// Schema for product update validation
const updateProductSchema = z.object({
name: z.string().min(1, "Name is required").optional(),
price: z.string().min(1, "Price is required").optional(),
numberPlate: z.string().min(1, "Number plate is required").optional(),
});
/**
* Creates a new product
*/
export async function createProduct(
data: ProductPayload
): Promise<ProductApiResponse> {
try {
// Validate data
const validated = productSchema.parse(data);
// Check if number plate already exists
const existingProduct = await db.product.findFirst({
where: { numberPlate: validated.numberPlate },
});
if (existingProduct) {
return {
success: false,
error: "A product with this number plate already exists",
};
}
// Create new product
const product = await db.product.create({
data: {
name: validated.name,
price: validated.price,
numberPlate: validated.numberPlate,
},
});
revalidatePath("/products");
return {
success: true,
data: product,
};
} catch (error) {
console.error("Error creating product:", error);
return {
success: false,
error:
error instanceof Error ? error.message : "Failed to create product",
};
}
}
/**
* Updates an existing product
*/
export async function editProduct(
id: string,
data: UpdateProductPayload
): Promise<ProductApiResponse> {
try {
// Validate the data partially (allowing optional fields)
const validated = updateProductSchema.parse(data);
// If number plate is provided, check if it already exists
if (validated.numberPlate) {
const existingProduct = await db.product.findFirst({
where: {
numberPlate: validated.numberPlate,
NOT: { id },
},
});
if (existingProduct) {
return {
success: false,
error: "A product with this number plate already exists",
};
}
}
// Update the product
const updatedProduct = await db.product.update({
where: { id },
data: validated,
});
revalidatePath("/products");
return {
success: true,
data: updatedProduct,
};
} catch (error) {
console.error("Error updating product:", error);
return {
success: false,
error:
error instanceof Error ? error.message : "Failed to update product",
};
}
}
/**
* Deletes a product
*/
export async function deleteProduct(id: string): Promise<ProductApiResponse> {
try {
// Check if product exists and has sales
const product = await db.product.findUnique({
where: { id },
include: { sales: { take: 1 } },
});
if (!product) {
return {
success: false,
error: "Product not found",
};
}
// Check if product has any sales
if (product.sales.length > 0) {
return {
success: false,
error: "Cannot delete product with existing sales records",
};
}
// Delete the product
await db.product.delete({
where: { id },
});
revalidatePath("/products");
return {
success: true,
};
} catch (error) {
console.error("Error deleting product:", error);
return {
success: false,
error:
error instanceof Error ? error.message : "Failed to delete product",
};
}
}
/**
* Gets all products
*/
export async function getAllProducts(): Promise<ProductApiResponse> {
try {
const products = await db.product.findMany({
orderBy: { createdAt: "desc" },
});
return {
success: true,
data: products,
};
} catch (error) {
console.error("Error fetching products:", error);
return {
success: false,
error:
error instanceof Error ? error.message : "Failed to fetch products",
};
}
}
/**
* Gets a single product by ID
*/
export async function getProductById(id: string): Promise<ProductApiResponse> {
try {
const product = await db.product.findUnique({
where: { id },
});
if (!product) {
return {
success: false,
error: "Product not found",
};
}
return {
success: true,
data: product,
};
} catch (error) {
console.error("Error fetching product:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Failed to fetch product",
};
}
}
/**
* Gets a product by number plate
*/
export async function getProductByNumberPlate(
numberPlate: string
): Promise<ProductApiResponse> {
try {
const product = await db.product.findUnique({
where: { numberPlate },
});
if (!product) {
return {
success: false,
error: "Product not found",
};
}
return {
success: true,
data: product,
};
} catch (error) {
console.error("Error fetching product by number plate:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Failed to fetch product",
};
}
}
Part 4: React Query Integration
- Now let's integrate React Query into our application for data fetching and state management.
Service Layer
First, create a service layer that wraps our server actions:
// services/product-service.ts
import {
createProduct,
editProduct,
deleteProduct,
getAllProducts,
getProductById,
getProductByNumberPlate,
} from "@/actions/products";
import type { ProductPayload, UpdateProductPayload } from "@/types/product";
// Centralized API object for all product-related server actions
export const productAPI = {
// Fetch all products
getAll: async () => {
const response = await getAllProducts();
if (!response.success) {
throw new Error(response.error || "Failed to fetch products");
}
return response.data;
},
// Get a single product by ID
getById: async (id: string) => {
const response = await getProductById(id);
if (!response.success) {
throw new Error(response.error || "Failed to fetch product");
}
return response.data;
},
// Get a product by number plate
getByNumberPlate: async (numberPlate: string) => {
const response = await getProductByNumberPlate(numberPlate);
if (!response.success) {
throw new Error(
response.error || "Failed to fetch product by number plate"
);
}
return response.data;
},
// Create a new product
create: async (data: ProductPayload) => {
const response = await createProduct(data);
if (!response.success) {
throw new Error(response.error || "Failed to create product");
}
return response.data;
},
// Update an existing product
update: async (id: string, data: UpdateProductPayload) => {
const response = await editProduct(id, data);
if (!response.success) {
throw new Error(response.error || "Failed to update product");
}
return response.data;
},
// Delete a product
delete: async (id: string) => {
const response = await deleteProduct(id);
if (!response.success) {
throw new Error(response.error || "Failed to delete product");
}
return true;
},
};
React Query Provider Setup
Now, let's set up the React Query provider:
// lib/providers/query-provider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState, ReactNode } from "react";
interface QueryProviderProps {
children: ReactNode;
}
export default function QueryProvider({ children }: QueryProviderProps) {
// Create a client for each session to prevent shared state between users
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Update the providers component
- Next, update your providers component to include the React Query provider:
"use client";
import { ourFileRouter } from "@/app/api/uploadthing/core";
import { NextSSRPlugin } from "@uploadthing/react/next-ssr-plugin";
import { SessionProvider } from "next-auth/react";
import React, { ReactNode } from "react";
import { Toaster } from "react-hot-toast";
import { Toaster as ShadToaster } from "@/components/ui/sonner";
import { extractRouterConfig } from "uploadthing/server";
import QueryProvider from "@/lib/query-provider";
export default function Providers({ children }: { children: ReactNode }) {
return (
<SessionProvider>
<QueryProvider>
<NextSSRPlugin routerConfig={extractRouterConfig(ourFileRouter)} />
<Toaster position="top-center" reverseOrder={false} />
<ShadToaster richColors />
{children}
</QueryProvider>
</SessionProvider>
);
}
React Query Hooks
Now let's create custom React Query hooks for our Product operations:
// hooks/useProductQueries.ts
import {
useQuery,
useSuspenseQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { productAPI } from "@/services/product-service";
import type {
Product,
ProductPayload,
UpdateProductPayload,
} from "@/types/product";
import { toast } from "sonner";
// Query keys for caching
export const productKeys = {
all: ["products"] as const,
lists: () => [...productKeys.all, "list"] as const,
list: (filters: any) => [...productKeys.lists(), { filters }] as const,
filteredList: (dateFilter: any, searchQuery: string) =>
[...productKeys.lists(), { dateFilter, searchQuery }] as const,
details: () => [...productKeys.all, "detail"] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};
/**
* Hook for fetching products with standard loading states
*/
export function useProducts() {
// Get all products with standard loading states
const {
data: products = [],
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: productKeys.lists(),
queryFn: productAPI.getAll,
});
return {
products,
isLoading,
isError,
error,
refetch,
};
}
/**
* Hook for fetching products with React Suspense
* Use this when the component is wrapped in a Suspense boundary
*/
export function useSuspenseProducts() {
// Get all products with Suspense (data is guaranteed to be defined)
const { data: products, refetch } = useSuspenseQuery({
queryKey: productKeys.lists(),
queryFn: productAPI.getAll,
});
return {
products,
refetch,
};
}
export function useProduct(id: string) {
// Get a single product
return useQuery({
queryKey: productKeys.detail(id),
queryFn: () => productAPI.getById(id),
enabled: Boolean(id), // Only run if ID is provided
});
}
export function useCreateProduct() {
const queryClient = useQueryClient();
// Create a new product
return useMutation({
mutationFn: (data: ProductPayload) => productAPI.create(data),
onSuccess: () => {
toast.success("Product added successfully");
// Invalidate products list to trigger a refetch
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
},
onError: (error: Error) => {
toast.error("Failed to add product", {
description: error.message || "Unknown error occurred",
});
},
});
}
export function useUpdateProduct() {
const queryClient = useQueryClient();
// Update an existing product
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateProductPayload }) =>
productAPI.update(id, data),
onSuccess: (data, variables) => {
toast.success("Product updated successfully");
// Invalidate specific product detail and list queries
queryClient.invalidateQueries({
queryKey: productKeys.detail(variables.id),
});
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
},
onError: (error: Error) => {
toast.error("Failed to update product", {
description: error.message || "Unknown error occurred",
});
},
});
}
export function useDeleteProduct() {
const queryClient = useQueryClient();
// Delete a product
return useMutation({
mutationFn: (id: string) => productAPI.delete(id),
onSuccess: () => {
toast.success("Product deleted successfully");
// Invalidate products list to trigger a refetch
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
},
onError: (error: Error) => {
toast.error("Failed to delete product", {
description: error.message || "Unknown error occurred",
});
},
});
}
This completes the React Query integration. Let's continue with building reusable UI components in the next part.
Part 5: Building Reusable UI Components
Let's create reusable UI components for our application, focusing on data tables, pagination, filtering, and forms.
- Table Loading Component First, let's create a loading component for our tables:
// components/ui/data-table/table-loading.tsx
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@/components/ui/table";
interface TableLoadingProps {
title?: string;
columnCount?: number;
rowCount?: number;
}
export default function TableLoading({
title = "Loading data",
columnCount = 6,
rowCount = 5,
}: TableLoadingProps) {
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle className="text-2xl font-bold">
<Skeleton className="h-8 w-64" />
</CardTitle>
<Skeleton className="h-5 w-80 mt-2" />
</div>
<Skeleton className="h-10 w-32" />
</CardHeader>
<CardContent className="px-0">
<div className="flex items-center justify-between px-6 mb-4">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<Table>
<TableHeader>
<TableRow>
{Array(columnCount)
.fill(0)
.map((_, i) => (
<TableHead key={i}>
<Skeleton className="h-4 w-full max-w-24" />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array(rowCount)
.fill(0)
.map((_, i) => (
<TableRow key={i}>
{Array(columnCount)
.fill(0)
.map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-5 w-full" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
- Rows Per Page Component Now, let's create a component for controlling the number of rows displayed per page:
// components/ui/rows-per-page.tsx
"use client";
import React from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface RowsPerPageProps {
value: number;
onChange: (value: number) => void;
options?: number[];
className?: string;
}
export default function RowsPerPage({
value,
onChange,
options = [5, 10, 25, 50, 100],
className = "",
}: RowsPerPageProps) {
return (
<div className={`flex items-center space-x-2 ${className}`}>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page:
</span>
<Select
value={value.toString()}
onValueChange={(val) => onChange(parseInt(val, 10))}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={value.toString()} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option.toString()}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
- Date Filter Component Let's create a date filter component:
// components/ui/date-filter.tsx
"use client";
import { useState } from "react";
import {
format,
startOfDay,
endOfDay,
subDays,
startOfMonth,
endOfMonth,
startOfYear,
endOfYear,
} from "date-fns";
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Calendar } from "@/components/ui/calendar";
import { cn } from "@/lib/utils";
import { DateRange as CalendarDateRange } from "react-day-picker";
// Define our own DateRange type for the component API
export type DateRange = {
from: Date | undefined;
to: Date | undefined;
};
export type DateFilterOption =
| "lifetime"
| "today"
| "last7days"
| "thisMonth"
| "thisYear"
| "custom";
interface DateFilterProps {
onFilterChange: (range: DateRange | null, option: DateFilterOption) => void;
}
export default function DateFilter({ onFilterChange }: DateFilterProps) {
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const [dateRange, setDateRange] = useState<CalendarDateRange | undefined>(
undefined
);
const [selectedOption, setSelectedOption] =
useState<DateFilterOption>("lifetime");
const handleOptionSelect = (option: DateFilterOption) => {
setSelectedOption(option);
let newRange: DateRange | null = null;
const today = new Date();
switch (option) {
case "lifetime":
newRange = null;
break;
case "today":
newRange = {
from: startOfDay(today),
to: endOfDay(today),
};
break;
case "last7days":
newRange = {
from: startOfDay(subDays(today, 6)),
to: endOfDay(today),
};
break;
case "thisMonth":
newRange = {
from: startOfMonth(today),
to: endOfMonth(today),
};
break;
case "thisYear":
newRange = {
from: startOfYear(today),
to: endOfYear(today),
};
break;
case "custom":
setIsCalendarOpen(true);
return; // Don't update filter yet, wait for user to select dates
}
onFilterChange(newRange, option);
};
const getFilterLabel = () => {
switch (selectedOption) {
case "lifetime":
return "All Time";
case "today":
return "Today";
case "last7days":
return "Last 7 Days";
case "thisMonth":
return "This Month";
case "thisYear":
return "This Year";
case "custom":
if (dateRange?.from && dateRange?.to) {
return `${format(dateRange.from, "MMM dd, yyyy")} - ${format(
dateRange.to,
"MMM dd, yyyy"
)}`;
}
return "Custom Range";
default:
return "All Time";
}
};
return (
<div className="flex items-center space-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="min-w-[180px] justify-between">
<span>{getFilterLabel()}</span>
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem onSelect={() => handleOptionSelect("lifetime")}>
All Time
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleOptionSelect("today")}>
Today
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleOptionSelect("last7days")}>
Last 7 Days
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleOptionSelect("thisMonth")}>
This Month
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleOptionSelect("thisYear")}>
This Year
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => handleOptionSelect("custom")}>
Custom Range
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{selectedOption === "custom" && (
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"justify-start text-left font-normal",
!dateRange?.from && !dateRange?.to && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateRange?.from && dateRange?.to ? (
<>
{format(dateRange.from, "MMM dd, yyyy")} -{" "}
{format(dateRange.to, "MMM dd, yyyy")}
</>
) : (
"Select date range"
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={(range) => {
setDateRange(range);
// Only trigger the filter change when both dates are selected
if (range?.from && range?.to) {
// Use setTimeout to break the render cycle
setTimeout(() => {
onFilterChange(
{
from: startOfDay(range.from),
to: endOfDay(range.to),
},
"custom"
);
}, 0);
}
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
)}
</div>
);
}
- Building Reusable Table Actions Components Now let's create reusable table action components:
// components/ui/data-table/table-actions.tsx
"use client";
import { useState } from "react";
import { Plus, Edit, Trash2, FileSpreadsheet, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import clsx from "clsx";
interface ActionButtonProps {
onClick: () => void;
disabled?: boolean;
loading?: boolean;
className?: string;
}
// Add Button
const AddButton = ({
onClick,
disabled = false,
loading = false,
className = "",
}: ActionButtonProps) => (
<Button
onClick={onClick}
disabled={disabled || loading}
className={className}
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Add New
</Button>
);
// Edit Button
const EditButton = ({
onClick,
disabled = false,
loading = false,
className = "",
}: ActionButtonProps) => (
<Button
variant="outline"
size="icon"
onClick={onClick}
disabled={disabled || loading}
title="Edit"
className={className}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Edit className="h-4 w-4" />
)}
</Button>
);
// Delete Button
const DeleteButton = ({
onClick,
disabled = false,
loading = false,
className = "",
}: ActionButtonProps) => (
<Button
variant="outline"
size="icon"
onClick={onClick}
// components/ui/data-table/table-actions.tsx (continued)
disabled={disabled || loading}
title="Delete"
className={clsx("text-destructive", className)}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
);
// Export Button
const ExportButton = ({
onClick,
disabled = false,
loading = false,
className = "",
}: ActionButtonProps) => {
return (
<Button
variant="outline"
onClick={onClick}
disabled={disabled || loading}
className={className}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Exporting...
</>
) : (
<>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Export
</>
)}
</Button>
);
};
// Row Actions
const RowActions = ({
onEdit,
onDelete,
isDeleting = false,
}: {
onEdit?: () => void;
onDelete?: () => void;
isDeleting?: boolean;
}) => {
return (
<div className="flex justify-end gap-2">
{onEdit && <EditButton onClick={onEdit} />}
{onDelete && <DeleteButton onClick={onDelete} loading={isDeleting} />}
</div>
);
};
// Export the components as a single object
const TableActions = {
AddButton,
EditButton,
DeleteButton,
ExportButton,
RowActions,
};
export default TableActions;
- Filter Bar Component Now, let's create a filter bar component:
// components/ui/data-table/filter-bar.tsx
"use client";
import { ReactNode } from "react";
import clsx from "clsx";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import DateFilter, {
DateRange,
DateFilterOption,
} from "@/components/ui/date-filter";
import TableActions from "./table-actions";
interface FilterBarProps {
searchQuery: string;
onSearchChange: (query: string) => void;
showDateFilter?: boolean;
dateFilter?: {
range: DateRange | null;
option: DateFilterOption;
};
onDateFilterChange?: (
range: DateRange | null,
option: DateFilterOption
) => void;
additionalFilters?: ReactNode;
onExport?: () => void;
}
export default function FilterBar({
searchQuery,
onSearchChange,
showDateFilter = false,
dateFilter,
onDateFilterChange,
additionalFilters,
onExport,
}: FilterBarProps) {
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 w-full">
{/* Search Input */}
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8 w-full"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1.5 h-6 w-6"
onClick={() => onSearchChange("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Date Filter */}
{showDateFilter && dateFilter && onDateFilterChange && (
<DateFilter onFilterChange={onDateFilterChange} />
)}
{/* Additional Custom Filters */}
{additionalFilters}
</div>
{/* Export Button */}
{onExport && (
<div className="w-full sm:w-auto">
<TableActions.ExportButton onClick={onExport} />
</div>
)}
</div>
);
}
- Entity Form Component Now let's create a reusable form component for creating and editing entities:
// components/ui/data-table/entity-form.tsx
"use client";
import { ReactNode } from "react";
import { UseFormReturn, FieldValues } from "react-hook-form";
import { Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
interface EntityFormProps<TFormValues extends FieldValues> {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
form: UseFormReturn<TFormValues>;
onSubmit: (values: TFormValues) => void | Promise<void>;
children: ReactNode;
isSubmitting?: boolean;
submitLabel?: string;
cancelLabel?: string;
size?: "sm" | "md" | "lg" | "xl";
disableWhenSubmitting?: boolean;
}
export default function EntityForm<TFormValues extends FieldValues>({
open,
onOpenChange,
title,
form,
onSubmit,
children,
isSubmitting = false,
submitLabel = "Save",
cancelLabel = "Cancel",
size = "sm",
disableWhenSubmitting = true,
}: EntityFormProps<TFormValues>) {
// Map size string to actual width class
const sizeClasses = {
sm: "sm:max-w-[425px]",
md: "sm:max-w-[550px]",
lg: "sm:max-w-[650px]",
xl: "sm:max-w-[800px]",
};
const widthClass = sizeClasses[size];
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (isSubmitting && disableWhenSubmitting) return;
onOpenChange(newOpen);
}}
>
<DialogContent className={widthClass}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{children}
<DialogFooter className="mt-6">
<DialogClose asChild>
<Button
type="button"
variant="outline"
disabled={isSubmitting && disableWhenSubmitting}
>
{cancelLabel}
</Button>
</DialogClose>
<Button
type="submit"
disabled={isSubmitting && disableWhenSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
submitLabel
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
- Confirmation Dialog Component Now let's create a reusable confirmation dialog component:
// components/ui/data-table/confirmation-dialog.tsx
"use client";
import { ReactNode } from "react";
import { Loader2, AlertTriangle } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
interface ConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string | ReactNode;
onConfirm: () => void;
isConfirming?: boolean;
confirmLabel?: string;
cancelLabel?: string;
variant?: "default" | "destructive";
}
export default function ConfirmationDialog({
open,
onOpenChange,
title,
description,
onConfirm,
isConfirming = false,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "default",
}: ConfirmationDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<div className="flex items-center gap-2">
{variant === "destructive" && (
<AlertTriangle className="h-5 w-5 text-destructive" />
)}
<AlertDialogTitle>{title}</AlertDialogTitle>
</div>
{description && (
<AlertDialogDescription>{description}</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isConfirming}>
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction
disabled={isConfirming}
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
className={cn(
variant === "destructive" &&
"bg-destructive hover:bg-destructive/90"
)}
>
{isConfirming ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
confirmLabel
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
- Data Table Component Finally, let's create the main data table component that integrates all the components we've created:
// components/ui/data-table/data-table.tsx
"use client";
import { useState, useEffect, ReactNode } from "react";
import clsx from "clsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw } from "lucide-react";
import RowsPerPage from "@/components/ui/rows-per-page";
import FilterBar from "./filter-bar";
import TableActions from "./table-actions";
import { DateRange, DateFilterOption } from "@/components/ui/date-filter";
export interface Column<T> {
header: string;
accessorKey: keyof T | ((row: T) => any);
cell?: (row: T) => ReactNode;
}
interface DataTableProps<T> {
title: string;
subtitle?: string;
data: T[];
columns: Column<T>[];
keyField: keyof T;
isLoading?: boolean;
onRefresh?: () => void;
actions?: {
onAdd?: () => void;
onEdit?: (item: T) => void;
onDelete?: (item: T) => void;
onExport?: (filteredData: T[]) => void;
};
filters?: {
searchFields?: (keyof T)[];
enableDateFilter?: boolean;
getItemDate?: (item: T) => Date | string;
additionalFilters?: ReactNode;
};
renderRowActions?: (item: T) => ReactNode;
emptyState?: ReactNode;
}
export default function DataTable<T>({
title,
subtitle,
data,
columns,
keyField,
isLoading = false,
onRefresh,
actions,
filters,
renderRowActions,
emptyState,
}: DataTableProps<T>) {
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(5);
// Filter state
const [searchQuery, setSearchQuery] = useState("");
const [dateFilter, setDateFilter] = useState<{
range: DateRange | null;
option: DateFilterOption;
}>({
range: null,
option: "lifetime",
});
// Reset page when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, dateFilter, itemsPerPage]);
// Apply search filter
const applySearchFilter = (items: T[]): T[] => {
if (!searchQuery.trim() || !filters?.searchFields?.length) return items;
const query = searchQuery.toLowerCase();
return items.filter((item) => {
return filters.searchFields!.some((field) => {
const value = item[field];
if (value === null || value === undefined) return false;
return String(value).toLowerCase().includes(query);
});
});
};
// Apply date filter
const applyDateFilter = (items: T[]): T[] => {
if (
!dateFilter.range?.from ||
!dateFilter.range?.to ||
!filters?.getItemDate
) {
return items;
}
const from = new Date(dateFilter.range.from);
const to = new Date(dateFilter.range.to);
return items.filter((item) => {
const itemDate = new Date(filters.getItemDate(item));
return itemDate >= from && itemDate <= to;
});
};
// Apply all filters
const filteredData = applyDateFilter(applySearchFilter(data));
// Calculate pagination
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredData.slice(indexOfFirstItem, indexOfLastItem);
// Handle page change
const handlePageChange = (pageNumber: number) => {
setCurrentPage(pageNumber);
};
// Generate page numbers for pagination
const getPageNumbers = () => {
const pageNumbers = [];
if (totalPages <= 5) {
// Show all pages if 5 or fewer
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// Show first page, current page and neighbors, and last page
if (currentPage <= 3) {
// Near the beginning
for (let i = 1; i <= 4; i++) {
pageNumbers.push(i);
}
pageNumbers.push("ellipsis");
pageNumbers.push(totalPages);
} else if (currentPage >= totalPages - 2) {
// Near the end
pageNumbers.push(1);
pageNumbers.push("ellipsis");
for (let i = totalPages - 3; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// Middle
pageNumbers.push(1);
pageNumbers.push("ellipsis");
pageNumbers.push(currentPage - 1);
pageNumbers.push(currentPage);
pageNumbers.push(currentPage + 1);
pageNumbers.push("ellipsis");
pageNumbers.push(totalPages);
}
}
return pageNumbers;
};
// Get value from accessorKey (which could be a string or function)
const getCellValue = (item: T, accessor: keyof T | ((row: T) => any)) => {
if (typeof accessor === "function") {
return accessor(item);
}
return item[accessor];
};
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-2xl">{title}</CardTitle>
{subtitle && <p className="text-muted-foreground mt-1">{subtitle}</p>}
{filters && (
<p className="text-muted-foreground mt-1">
{filteredData.length}{" "}
{filteredData.length === 1 ? "item" : "items"}
{dateFilter.option !== "lifetime" && <> | Date filter applied</>}
</p>
)}
</div>
<div className="flex items-center gap-2">
{onRefresh && (
<Button
variant="outline"
size="icon"
onClick={onRefresh}
disabled={isLoading}
title="Refresh data"
>
<RefreshCw
className={clsx("h-4 w-4", isLoading && "animate-spin")}
/>
</Button>
)}
{actions?.onAdd && <TableActions.AddButton onClick={actions.onAdd} />}
</div>
</CardHeader>
<CardContent>
{/* Filter bar */}
{filters && (
<FilterBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
showDateFilter={filters.enableDateFilter}
dateFilter={dateFilter}
onDateFilterChange={(range, option) =>
setDateFilter({ range, option })
}
additionalFilters={filters.additionalFilters}
onExport={
actions?.onExport
? () => actions.onExport(filteredData)
: undefined
}
/>
)}
{/* Table */}
<Table>
<TableHeader>
<TableRow>
{columns.map((column, index) => (
<TableHead key={index}>{column.header}</TableHead>
))}
{renderRowActions && (
<TableHead className="text-right">Actions</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{currentItems.length > 0 ? (
currentItems.map((item) => (
<TableRow key={String(item[keyField])}>
{columns.map((column, index) => (
<TableCell key={index}>
{column.cell
? column.cell(item)
: getCellValue(item, column.accessorKey)}
</TableCell>
))}
{renderRowActions && (
<TableCell className="text-right">
{renderRowActions(item)}
</TableCell>
)}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length + (renderRowActions ? 1 : 0)}
className="text-center py-6"
>
{emptyState ||
(searchQuery || dateFilter.option !== "lifetime"
? "No matching items found for the selected filters"
: "No items found")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* Pagination */}
{filteredData.length > 0 && (
<div className="mt-4 flex flex-col sm:flex-row justify-between items-center">
<div className="mb-2 sm:mb-0">
<RowsPerPage
value={itemsPerPage}
onChange={setItemsPerPage}
options={[5, 10, 25, 50, 100]}
/>
</div>
<div className="text-sm text-muted-foreground">
Showing {indexOfFirstItem + 1}-
{Math.min(indexOfLastItem, filteredData.length)} of{" "}
{filteredData.length}
</div>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() =>
handlePageChange(Math.max(1, currentPage - 1))
}
className={clsx(
currentPage === 1
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
{getPageNumbers().map((page, index) =>
page === "ellipsis" ? (
<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={`page-${page}`}>
<PaginationLink
onClick={() => handlePageChange(page as number)}
className={clsx(
currentPage === page
? "bg-primary text-primary-foreground"
: "cursor-pointer"
)}
>
{page}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
onClick={() =>
handlePageChange(Math.min(totalPages, currentPage + 1))
}
className={clsx(
currentPage === totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
)}
</CardContent>
</Card>
);
}
- Export All Components in a Module Finally, let's create an index file to export all our data table components:
// components/ui/data-table/index.ts
import DataTable, { Column } from "./data-table";
import FilterBar from "./filter-bar";
import TableActions from "./table-actions";
import EntityForm from "./entity-form";
import ConfirmationDialog from "./confirmation-dialog";
import TableLoading from "./table-loading";
export {
DataTable,
FilterBar,
TableActions,
EntityForm,
ConfirmationDialog,
TableLoading,
};
export type { Column };
This completes our reusable UI components. In the next part, we'll implement the Product Management feature using these components.
Part 6: Product Management Implementation
Now that we've created all the necessary components, let's implement the actual Product Management feature.
- Product Listing Component with Suspense Support
// components/ui/groups/product-listing-suspense.tsx
"use client";
import { useState, useEffect } from "react";
import { format } from "date-fns";
import * as XLSX from "xlsx";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { Car, DollarSign } from "lucide-react";
import { useSession } from "next-auth/react";
import {
DataTable,
Column,
TableActions,
EntityForm,
ConfirmationDialog,
} from "@/components/ui/data-table";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
useSuspenseProducts,
useCreateProduct,
useUpdateProduct,
useDeleteProduct,
} from "@/hooks/useProductQueries";
import type { Product } from "@/types/product";
interface ProductDetailProps {
title: string;
}
// Form schema for editing/adding products
const productFormSchema = z.object({
name: z.string().min(1, "Name is required"),
price: z.string().min(1, "Price is required"),
numberPlate: z.string().min(1, "Number plate is required"),
});
type ProductFormValues = z.infer<typeof productFormSchema>;
export default function ProductDetail({ title }: ProductDetailProps) {
// React Query hooks with Suspense - note that data is always defined
const { products, refetch } = useSuspenseProducts();
const createProductMutation = useCreateProduct();
const updateProductMutation = useUpdateProduct();
const deleteProductMutation = useDeleteProduct();
// Local state
const [formDialogOpen, setFormDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [currentProduct, setCurrentProduct] = useState<Product | null>(null);
const [productToDelete, setProductToDelete] = useState<Product | null>(null);
// Form for editing/adding products
const form = useForm<ProductFormValues>({
resolver: zodResolver(productFormSchema),
defaultValues: {
name: "",
price: "",
numberPlate: "",
},
});
// Update form when current product changes
useEffect(() => {
if (!currentProduct) {
// Adding new - reset form
form.reset({
name: "",
price: "",
numberPlate: "",
});
} else {
// Editing existing - populate form
form.reset({
name: currentProduct.name,
price: currentProduct.price,
numberPlate: currentProduct.numberPlate,
});
}
}, [currentProduct, form]);
const { data: session } = useSession();
// Format date function
const formatDate = (date: Date | string) => {
const dateObj = typeof date === "string" ? new Date(date) : date;
return format(dateObj, "MMM dd, yyyy");
};
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-UG", {
style: "currency",
currency: "UGX",
minimumFractionDigits: 0,
}).format(amount);
};
// Export to Excel
const handleExport = async (filteredProducts: Product[]) => {
setIsExporting(true);
try {
// Prepare data for export
const exportData = filteredProducts.map((product) => ({
Name: product.name,
"Number Plate": product.numberPlate,
Price: product.price,
"Sales Count": product.salesCount,
"Total Sales": formatCurrency(product.salesTotal),
"Date Added": formatDate(product.createdAt),
}));
// Create workbook and worksheet
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Products");
// Generate filename with current date
const fileName = `Products_${format(new Date(), "yyyy-MM-dd")}.xlsx`;
// Export to file
XLSX.writeFile(workbook, fileName);
toast.success("Export successful", {
description: `Products exported to ${fileName}`,
});
} catch (error) {
toast.error("Export failed", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
} finally {
setIsExporting(false);
}
};
// Handle add new click
const handleAddClick = () => {
setCurrentProduct(null);
setFormDialogOpen(true);
};
// Handle edit click
const handleEditClick = (product: Product) => {
setCurrentProduct(product);
setFormDialogOpen(true);
};
// Handle delete click
const handleDeleteClick = (product: Product) => {
setProductToDelete(product);
setDeleteDialogOpen(true);
};
// Handle form submission (edit or add)
const onSubmit = async (data: ProductFormValues) => {
if (!currentProduct) {
// Add new product
createProductMutation.mutate(data);
} else {
// Edit existing product
updateProductMutation.mutate({
id: currentProduct.id,
data,
});
}
};
// Handle confirming delete
const handleConfirmDelete = () => {
if (productToDelete) {
deleteProductMutation.mutate(productToDelete.id);
}
};
// Calculate total products value
const getTotalValue = (products: Product[]) => {
return products.reduce((total, product) => {
const price = parseFloat(product.price.replace(/[^0-9.]/g, "")) || 0;
return total + price;
}, 0);
};
// Define columns for the data table
const columns: Column<Product>[] = [
{
header: "Name",
accessorKey: "name",
cell: (row) => <span className="font-medium">{row.name}</span>,
},
{
header: "Number Plate",
accessorKey: "numberPlate",
},
{
header: "Price",
accessorKey: "price",
},
{
header: "Sales Count",
accessorKey: "salesCount",
},
{
header: "Total Sales",
accessorKey: (row) => formatCurrency(row.salesTotal),
},
{
header: "Date Added",
accessorKey: (row) => formatDate(row.createdAt),
},
];
// Generate subtitle with total value
const getSubtitle = (productCount: number, totalValue: number) => {
return `${productCount} ${
productCount === 1 ? "product" : "products"
} | Total Value: ${formatCurrency(totalValue)}`;
};
return (
<>
<DataTable<Product>
title={title}
subtitle={
products.length > 0
? getSubtitle(products.length, getTotalValue(products))
: undefined
}
data={products}
columns={columns}
keyField="id"
isLoading={false} // With Suspense, we're guaranteed to have data
onRefresh={refetch}
actions={{
onAdd: handleAddClick,
onExport: handleExport,
}}
filters={{
searchFields: ["name", "numberPlate"],
enableDateFilter: true,
getItemDate: (item) => item.createdAt,
}}
renderRowActions={(item) => (
<TableActions.RowActions
onEdit={() => handleEditClick(item)}
onDelete={() => handleDeleteClick(item)}
isDeleting={
deleteProductMutation.isPending && productToDelete?.id === item.id
}
/>
)}
/>
{/* Product Form Dialog */}
<EntityForm
open={formDialogOpen}
onOpenChange={setFormDialogOpen}
title={currentProduct ? "Edit Product" : "Add New Product"}
form={form}
onSubmit={onSubmit}
isSubmitting={
createProductMutation.isPending || updateProductMutation.isPending
}
submitLabel={currentProduct ? "Save Changes" : "Add Product"}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Product Name</FormLabel>
<FormControl>
<Input placeholder="Enter product name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>Price</FormLabel>
<FormControl>
<div className="relative">
<DollarSign className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="25,000,000" className="pl-8" {...field} />
</div>
</FormControl>
<FormDescription>Enter the product price in UGX</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="numberPlate"
render={({ field }) => (
<FormItem>
<FormLabel>Number Plate</FormLabel>
<FormControl>
<div className="relative">
<Car className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="UAX 123B"
className="pl-8"
{...field}
disabled={!!currentProduct}
/>
</div>
</FormControl>
<FormDescription>
{!currentProduct
? "Enter the unique number plate for this product"
: "Number plate cannot be changed after creation"}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</EntityForm>
{/* Delete Confirmation Dialog */}
<ConfirmationDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="Delete Product"
description={
productToDelete ? (
<>
Are you sure you want to delete{" "}
<strong>{productToDelete.name}</strong> (
{productToDelete.numberPlate})? This action cannot be undone.
</>
) : (
"Are you sure you want to delete this product?"
)
}
onConfirm={handleConfirmDelete}
isConfirming={deleteProductMutation.isPending}
confirmLabel="Delete"
variant="destructive"
/>
</>
);
}
Part 7: Product Page
Now that we've created the product listing component, let's use this component on the product page.
// app/products/page.tsx
import { Suspense } from "react";
import ProductDetail from "@/components/ui/groups/product-listing";
import { TableLoading } from "@/components/ui/data-table";
export default function ProductsPage() {
return (
<div className="container py-8">
<Suspense fallback={<TableLoading title="Vehicle Inventory" />}>
<ProductDetail title="Vehicle Inventory" />
</Suspense>
</div>
);
}