Back to blog

Tuesday, March 18, 2025

React Query, Modern Modal Forms and Reusable Custom Data table

cover

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

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.

  1. 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>
  );
}
  1. 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>
  );
}
  1. 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>
  );
}
  1. 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;
  1. 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>
  );
}
  1. 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>
  );
}
  1. 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>
  );
}
  1. 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>
  );
}
  1. 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.

  1. 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>
  );
}