Thursday, July 17, 2025
React Query and Reusable Data tables
Posted by
SOAPI: Smart Optimized API Interaction
Complete Frontend Development Guide
Table of Contents
- SOAPI Principles Overview
- Project Setup
- API Configuration
- API Service Layer
- React Query Integration
- Data Table Components
- CRUD Operations Implementation
- Update Forms with Tabs
SOAPI Principles Overview
SOAPI stands for: Small payloads, Optimized queries, Atomic updates, Protected deletes, Intelligent responses
Core Principles
🚀 Small Payloads (S)
- Create operations use maximum 5 fields
- Use modals for quick creation
- Complete details via update operations
🎯 Optimized Queries (O)
- Always use
select
to fetch only required fields - Different queries for different UI needs (list vs detail)
⚡ Atomic Updates (A)
- Use PATCH instead of PUT
- Update only changed fields
- Group related fields in UI cards
🛡️ Protected Deletes (P)
- Implement soft deletes when possible
- Check for relationships before deletion
- Provide deactivation alternatives
🧠 Intelligent Responses (I)
- Return IDs instead of full objects
- Minimize response payload
- Use React Query for smart caching
Project Setup
1. Install Dependencies
npm install axios @tanstack/react-query sonner
npm install @hookform/resolvers/zod react-hook-form zod
npm install lucide-react date-fns clsx
2. Directory Structure
src/
├── config/
│ └── axios.ts
├── services/
│ └── products.ts
├── hooks/
│ └── useProducts.ts
├── components/
│ ├── ui/
│ │ └── data-table/
│ │ ├── index.ts
│ │ ├── data-table.tsx
│ │ ├── table-actions.tsx
│ │ ├── filter-bar.tsx
│ │ ├── entity-form.tsx
│ │ ├── confirmation-dialog.tsx
│ │ ├── table-loading.tsx
│ │ ├── date-filter.tsx
│ │ └── rows-per-page.tsx
│ └── products/
│ └── product-listing.tsx
└── app/
└── products/
├── page.tsx
└── [id]/
└── edit/
├── page.tsx
├── layout.tsx
├── edit-loading.tsx
├── product-update-form.tsx
└── tabs/
├── basic-info-tab.tsx
├── inventory-pricing-tab.tsx
└── additional-details-tab.tsx
API Configuration
config/axios.ts
import axios from "axios";
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL;
// Create a base axios instance without authentication
const baseApi = axios.create({
baseURL: `${BASE_URL}/api/v1`,
headers: {
"Content-Type": "application/json",
},
});
export { baseApi as api };
API Service Layer
services/products.ts
import { api } from "@/config/axios";
import {
ProductCreateDTO,
ProductResponse,
BriefProductsResponse,
ProductData,
} from "@/types/product";
// Create product with minimal payload (SOAPI: Small Payloads)
export async function createProduct(data: ProductCreateDTO) {
try {
const res = await api.post("/products", data);
return {
success: true,
data: res.data.data, // Return created product ID
error: null,
};
} catch (error) {
console.log(error);
return {
success: false,
error: "Failed to Create Product",
data: null,
};
}
}
// Get optimized product list (SOAPI: Optimized Queries)
export async function getBriefProducts(
params = {}
): Promise<BriefProductsResponse> {
try {
const res = await api.get("/products/brief", { params });
return res.data;
} catch (error) {
console.log(error);
if (axios.isAxiosError(error)) {
throw new Error(
error.response?.data?.error || "Failed to fetch products"
);
}
throw new Error("An unexpected error occurred");
}
}
// Get full product details
export async function getProductById(id: string): Promise<ProductResponse> {
try {
const res = await api.get(`/products/${id}`);
return res.data;
} catch (error) {
console.log(error);
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || "Failed to fetch product");
}
throw new Error("An unexpected error occurred");
}
}
// Update product with only changed fields (SOAPI: Atomic Updates)
export async function updateProductById(
id: string,
data: Partial<ProductData>
) {
try {
const res = await api.patch(`/products/${id}`, data);
return {
success: true,
data: res.data.id, // Return only ID (SOAPI: Intelligent Responses)
};
} catch (error) {
console.log(error);
return { success: false };
}
}
// Protected delete with relationship checking (SOAPI: Protected Deletes)
export async function deleteProduct(id: string) {
try {
const res = await api.delete(`/products/${id}`);
return {
success: true,
data: res.data.id,
error: null,
};
} catch (error) {
console.log(error);
return {
success: false,
data: null,
error: "Failed to delete the Product",
};
}
}
Type Definitions (types/product.ts)
export interface ProductCreateDTO {
name: string;
sellingPrice: number;
costPrice: number;
sku: string;
thumbnail?: string;
}
export interface BriefProductDTO {
id: string;
name: string;
sellingPrice: number;
salesCount: number;
salesTotal: number;
thumbnail: string | null;
createdAt: Date;
}
export interface ProductData extends BriefProductDTO {
slug: string;
sku: string;
barcode?: string;
description?: string;
dimensions?: string;
weight?: number;
costPrice: number;
minStockLevel: number;
maxStockLevel?: number;
isActive: boolean;
isSerialTracked: boolean;
categoryId?: string;
brandId?: string;
unitId?: string;
taxRateId?: string;
tax?: number;
unitOfMeasure?: string;
imageUrls?: string[];
// Additional fields for extended details
upc?: string;
ean?: string;
mpn?: string;
isbn?: string;
updatedAt: Date;
}
export interface BriefProductsResponse {
data: BriefProductDTO[];
total: number;
}
export interface ProductResponse {
data: ProductData;
success: boolean;
}
React Query Integration
hooks/useProducts.ts
import {
createProduct,
getBriefProducts,
getProductById,
updateProductById,
deleteProduct,
} from "@/services/products";
import { ProductCreateDTO } from "@/types/product";
import {
useQuery,
useSuspenseQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
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,
details: () => [...productKeys.all, "detail"] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};
export function useProducts() {
const { data: products, refetch } = useSuspenseQuery({
queryKey: productKeys.lists(),
queryFn: () => getBriefProducts(),
});
return {
products: products.data,
refetch,
};
}
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ProductCreateDTO) => createProduct(data),
onSuccess: () => {
toast.success("Product added successfully");
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
},
onError: (error: Error) => {
toast.error("Failed to add Product", {
description: error.message || "Unknown error occurred",
});
},
});
}
export function useDeleteProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteProduct(id),
onSuccess: () => {
toast.success("Product deleted successfully");
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
},
onError: (error: Error) => {
toast.error("Failed to delete product", {
description: error.message || "Unknown error occurred",
});
},
});
}
CRUD Operations Implementation
Product Listing Page
app/products/page.tsx
import { Suspense } from "react";
import { TableLoading } from "@/components/ui/data-table";
import ProductListing from "@/components/products/product-listing";
// Create an async component for data fetching
async function ProductListingWithData() {
return (
<ProductListing title="Products" subtitle="Manage your product catalog" />
);
}
export default function ProductsPage() {
return (
<div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<Suspense fallback={<TableLoading />}>
<ProductListingWithData />
</Suspense>
</div>
);
}
components/products/product-listing.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 { DollarSign } from "lucide-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 {
useCreateProduct,
useDeleteProduct,
useProducts,
} from "@/hooks/useProducts";
import { BriefProductDTO, ProductCreateDTO } from "@/types/product";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
interface ProductListingProps {
title: string;
subtitle: string;
}
// SOAPI: Small Payloads - Only 5 fields maximum for creation
const productFormSchema = z.object({
name: z.string().min(1, "Name is required"),
sellingPrice: z.string().min(1, "Price is required"),
costPrice: z.string().min(1, "Cost price is required"),
sku: z.string().min(1, "SKU is required"),
thumbnail: z.string().optional(),
});
type ProductFormValues = z.infer<typeof productFormSchema>;
export default function ProductListing({
title,
subtitle,
}: ProductListingProps) {
// SOAPI: React Query with Suspense
const { products, refetch } = useProducts();
const createProductMutation = useCreateProduct();
const deleteProductMutation = useDeleteProduct();
// Local state
const [formDialogOpen, setFormDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [productToDelete, setProductToDelete] =
useState<BriefProductDTO | null>(null);
// SOAPI: Small payload form
const form = useForm<ProductCreateDTO>({
resolver: zodResolver(productFormSchema),
defaultValues: {
name: "",
sellingPrice: 0,
costPrice: 0,
sku: "",
thumbnail:
"https://14j7oh8kso.ufs.sh/f/HLxTbDBCDLwfAXaapcezIN7vwylkF1PXSCqAuseUG0gx8mhd",
},
});
const router = useRouter();
// 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: BriefProductDTO[]) => {
try {
const exportData = filteredProducts.map((product) => ({
Name: product.name,
Price: product.sellingPrice,
"Sales Count": product.salesCount,
"Total Sales": formatCurrency(product.salesTotal),
"Date Added": format(new Date(product.createdAt), "MMM dd, yyyy"),
}));
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Products");
const fileName = `Products_${format(new Date(), "yyyy-MM-dd")}.xlsx`;
XLSX.writeFile(workbook, fileName);
toast.success("Export successful", {
description: `Products exported to ${fileName}`,
});
} catch (error) {
toast.error("Export failed");
}
};
// Handle add new click
const handleAddClick = () => {
setFormDialogOpen(true);
};
// Handle edit click - Navigate to edit page
const handleEditClick = (product: BriefProductDTO) => {
router.push(`/products/${product.id}/edit`);
};
// Handle delete click
const handleDeleteClick = (product: BriefProductDTO) => {
setProductToDelete(product);
setDeleteDialogOpen(true);
};
// SOAPI: Small payload submission
const onSubmit = async (data: ProductCreateDTO) => {
// Convert string prices to numbers
data.costPrice = Number(data.costPrice);
data.sellingPrice = Number(data.sellingPrice);
createProductMutation.mutate(data, {
onSuccess: () => {
setFormDialogOpen(false);
form.reset();
},
});
};
// Handle confirming delete
const handleConfirmDelete = () => {
if (productToDelete) {
deleteProductMutation.mutate(productToDelete.id, {
onSuccess: () => {
setDeleteDialogOpen(false);
setProductToDelete(null);
},
});
}
};
// SOAPI: Optimized columns - only display what's needed for list view
const columns: Column<BriefProductDTO>[] = [
{
header: "Image",
accessorKey: "thumbnail",
cell: (row) => (
<img
className="w-10 h-10 rounded-md object-cover"
src={row.thumbnail ?? "/placeholder.png"}
alt={row.name}
/>
),
},
{
header: "Name",
accessorKey: "name",
cell: (row) => (
<span className="font-medium line-clamp-1">
{row.name.length > 20 ? `${row.name.substring(0, 20)}...` : row.name}
</span>
),
},
{
header: "Price",
accessorKey: (row) => formatCurrency(row.sellingPrice),
},
{
header: "Sales Count",
accessorKey: "salesCount",
},
{
header: "Total Sales",
accessorKey: (row) => formatCurrency(row.salesTotal),
},
];
return (
<>
<DataTable<BriefProductDTO>
title={title}
subtitle={subtitle}
data={products}
columns={columns}
keyField="id"
isLoading={false}
onRefresh={refetch}
actions={{
onAdd: handleAddClick,
onExport: handleExport,
}}
filters={{
searchFields: ["name"],
enableDateFilter: true,
getItemDate: (item) => item.createdAt,
}}
renderRowActions={(item) => (
<TableActions.RowActions
onEdit={() => handleEditClick(item)}
onDelete={() => handleDeleteClick(item)}
isDeleting={
deleteProductMutation.isPending && productToDelete?.id === item.id
}
/>
)}
/>
{/* SOAPI: Small Payload Form - Only 5 fields */}
<EntityForm
size="md"
open={formDialogOpen}
onOpenChange={setFormDialogOpen}
title="Add New Product"
form={form}
onSubmit={onSubmit}
isSubmitting={createProductMutation.isPending}
submitLabel="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>
)}
/>
<div className="grid gap-3 grid-cols-2">
<FormField
control={form.control}
name="sellingPrice"
render={({ field }) => (
<FormItem>
<FormLabel>Selling 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" className="pl-8" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="costPrice"
render={({ field }) => (
<FormItem>
<FormLabel>Cost Price</FormLabel>
<FormControl>
<div className="relative">
<DollarSign className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="20,000" className="pl-8" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="sku"
render={({ field }) => (
<FormItem>
<FormLabel>Product SKU</FormLabel>
<FormControl>
<Input placeholder="SKU-001" {...field} />
</FormControl>
<FormDescription>Unique product identifier</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>? This action cannot be
undone.
</>
) : (
"Are you sure you want to delete this product?"
)
}
onConfirm={handleConfirmDelete}
isConfirming={deleteProductMutation.isPending}
confirmLabel="Delete"
variant="destructive"
/>
</>
);
}
AN EXAMPLE PAGE FOR COURSES
Courses page
import { Suspense } from "react";
import { TableLoading } from "@/components/ui/data-table";
import DashboardCourseListing from "@/components/Lists/DashboardCourseListing";
import { getCourses } from "@/actions/courses";
import { getAuthUser } from "@/config/useAuth";
// Create an async component for data fetching
async function CourseListingWithData() {
const user = await getAuthUser();
const courses = (await getCourses()) || [];
return (
<DashboardCourseListing
title={`Courses (${courses.length})`}
subtitle="Manage Courses"
courses={courses}
instructorId={user?.id ?? ""}
/>
);
}
export default function Courses() {
return (
<div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<Suspense fallback={<TableLoading />}>
<CourseListingWithData />
</Suspense>
</div>
);
}
DashboardCourseListing
"use client";
export type CourseBrief = {
id: string;
title: string;
thumbnail: string | null;
students: number;
createdAt: Date;
};
import { useState } from "react";
import * as XLSX from "xlsx";
// import DataTable from '../ReUsableDataTable/DataTable';
import { formatDate, formatDate2, formatISODate } from "@/lib/utils";
import { format } from "date-fns";
import { toast } from "sonner";
import {
Column,
ConfirmationDialog,
DataTable,
TableActions,
} from "../ui/data-table";
import { useRouter } from "next/navigation";
import CourseCreateForm from "@/app/(dashboard)/dashboard/courses/components/CourseCreateForm";
import { Button } from "../ui/button";
import { deleteCourse } from "@/actions/courses";
import { Pencil } from "lucide-react";
export default function DashboardCourseListing({
courses,
title,
subtitle,
instructorId,
}: {
courses: CourseBrief[];
title: string;
subtitle: string;
instructorId: string;
}) {
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteItem, setDeleteItem] = useState<CourseBrief | null>(null);
const [modalOpen, setModalOpen] = useState(false);
console.log(deleteItem);
const columns: Column<CourseBrief>[] = [
{
accessorKey: "title",
header: "Course title",
cell: (row) => {
const course = row;
const image = course.thumbnail || "/default.png";
return (
<div className="flex items-center gap-3">
<img
src={`${image}`}
alt={course.title}
className="h-10 w-10 rounded-md object-cover"
/>
<span className="font-medium">{course.title}</span>
</div>
);
},
},
{
accessorKey: "students",
header: "Students",
cell: (row) => {
const course = row;
return (
<div className="">
<span className="font-medium">{course.students}</span>
</div>
);
},
},
{
accessorKey: "id",
header: "Add Module",
cell: (row) => {
const course = row;
return (
<div className="">
<Button variant={"outline"} href={`/course-module/${course.id}`}>
<Pencil className="w-4 h-4" />
Add Course Modules
</Button>
</div>
);
},
},
{
header: "Date Added",
accessorKey: (row) => formatISODate(row.createdAt),
},
];
const handleAddNew = () => {
setModalOpen(true);
};
// Export to Excel
const handleExport = async (filteredCourses: CourseBrief[]) => {
try {
// Prepare data for export
const exportData = filteredCourses.map((course) => ({
ID: course.id,
Title: course.title,
Thumbnail: course.thumbnail,
"Date Added": formatISODate(course.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 edit click
const handleEditClick = (course: CourseBrief) => {
router.push(`/dashboard/courses/${course.id}/edit`);
// setEditingCategory(category);
// setModalOpen(true);
};
// Handle delete click
const handleDeleteClick = (course: CourseBrief) => {
setDeleteItem(course);
setDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (deleteItem) {
setIsDeleting(true);
const count = deleteItem.students ?? 0;
if (count > 0) {
toast.error("Delete Failed", {
description: "This Course has associated students",
});
setDeleteDialogOpen(false);
setIsDeleting(false);
return;
}
await deleteCourse(deleteItem.id);
console.log("Deleting Course with ID:", deleteItem.id);
}
};
return (
<div className="container mx-auto py-6">
<CourseCreateForm
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
userId={instructorId}
/>
<DataTable<CourseBrief>
title={title}
subtitle={subtitle}
data={courses}
columns={columns}
keyField="id"
isLoading={false} // With Suspense, we're guaranteed to have data
onRefresh={() => console.log("refreshing")}
actions={{
onAdd: handleAddNew,
onExport: handleExport,
}}
filters={{
searchFields: ["title"],
enableDateFilter: true,
getItemDate: (item) => item.createdAt,
}}
renderRowActions={(item) => (
<TableActions.RowActions
onEdit={() => handleEditClick(item)}
onDelete={() => handleDeleteClick(item)}
// isDeleting={isDeleting}
/>
)}
/>
<ConfirmationDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="Delete Course"
description={
deleteItem ? (
<>
Are you sure you want to delete{" "}
<strong>{deleteItem.title}</strong>? This action cannot be undone.
</>
) : (
"Are you sure you want to delete this Department?"
)
}
onConfirm={handleConfirmDelete}
isConfirming={isDeleting}
confirmLabel="Delete"
variant="destructive"
/>
</div>
);
}
The Course create form
"use client";
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Check, DollarSign, FolderPlus, Pen, Pencil, Plus } from "lucide-react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import TextInput from "@/components/FormInputs/TextInput";
import SubmitButton from "@/components/FormInputs/SubmitButton";
import { CourseCreateDTO } from "@/types/course";
import { toast } from "sonner";
import TextArea from "@/components/FormInputs/TextAreaInput";
import { generateSlug } from "@/lib/generateSlug";
import { createCourse } from "@/actions/courses";
import { useRouter } from "next/navigation";
export default function CourseCreateForm({
userId,
initialContent,
editingId,
isOpen,
onClose,
}: {
userId: string;
initialContent?: string;
editingId?: string;
isOpen: boolean;
onClose: () => void;
}) {
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CourseCreateDTO>({
defaultValues: {
title: "",
summary: "",
},
});
const [loading, setLoading] = useState(false);
const router = useRouter();
async function saveCourse(data: CourseCreateDTO) {
data.instructorId = userId;
data.slug = generateSlug(data.title);
data.price = Number(data.price);
try {
setLoading(true);
const res = await createCourse(data);
if (res.error) {
toast.error("Failed to Create Course", {
description: res.error,
});
return;
}
setLoading(false);
toast.success("Successfully Created!");
reset();
setTimeout(() => {
router.push(`/dashboard/courses/${res.data?.id}/edit`);
}, 3000);
} catch (error) {
toast.error("Something went wrong");
setLoading(false);
console.log(error);
}
}
return (
<div>
<div className="py-1">
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
reset();
onClose();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingId ? "Edit Course" : "Add New Course"}
</DialogTitle>
</DialogHeader>
<form className="" onSubmit={handleSubmit(saveCourse)}>
<div className="">
<div className="space-y-3">
<div className="grid gap-3">
<TextInput
register={register}
errors={errors}
label="Course title"
name="title"
icon={Check}
/>
</div>
<div className="grid gap-3">
<TextInput
register={register}
errors={errors}
label="Course Price"
name="price"
type="number"
icon={DollarSign}
/>
</div>
<div className="grid gap-3">
<TextArea
register={register}
errors={errors}
label="Course Summary Description"
name="summary"
helperText="The summary should short to 125 characters"
/>
</div>
</div>
<div className="py-3 flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={() => {
reset();
onClose();
}}
>
Cancel
</Button>
<SubmitButton
title={editingId ? "Update" : "Add Course"}
loading={loading}
/>
</div>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</div>
);
}
OTHER FORM COMPONENTS
Update Forms with Tabs
Product Edit Page Structure
app/products/[id]/edit/layout.tsx
import React, { ReactNode } from "react";
export default async function Layout({ children }: { children: ReactNode }) {
// Add any permission checks here if needed
return <div>{children}</div>;
}
app/products/[id]/edit/page.tsx
import { notFound } from "next/navigation";
import { ProductUpdateForm } from "./product-update-form";
import { getProductById } from "@/services/products";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { Suspense } from "react";
import EditProductLoading from "./edit-loading";
export default async function EditProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const id = (await params).id;
const { data: product, success } = await getProductById(id);
if (!product) {
notFound();
}
// Mock options - replace with actual API calls
const brandOptions = [
{ label: "Nike", value: "1" },
{ label: "Adidas", value: "2" },
];
const categoryOptions = [
{ label: "Shoes", value: "1" },
{ label: "Clothing", value: "2" },
];
const taxOptions = [
{ label: "VAT-18%", value: "1" },
{ label: "GST-12%", value: "2" },
];
const unitOptions = [
{ label: "Piece-pcs", value: "1" },
{ label: "Kilogram-kg", value: "2" },
];
return (
<Suspense fallback={<EditProductLoading />}>
<div className="container">
<div className="mb-8 space-y-4">
<div className="flex items-center gap-2">
<Link href="/products">
<Button variant="ghost" size="icon" className="rounded-full">
<ArrowLeft className="h-5 w-5" />
<span className="sr-only">Back to products</span>
</Button>
</Link>
<div className="text-sm text-muted-foreground">
<Link href="/products" className="hover:underline">
Products
</Link>{" "}
/ <span>Edit</span>
</div>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{product.name}
</h1>
<p className="text-muted-foreground mt-1">
SKU: {product.sku} • Last updated:{" "}
{new Date(product.updatedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline">Preview</Button>
</div>
</div>
</div>
<ProductUpdateForm
brandOptions={brandOptions}
categoryOptions={categoryOptions}
unitOptions={unitOptions}
taxOptions={taxOptions}
product={product}
/>
</div>
</Suspense>
);
}
app/products/[id]/edit/product-update-form.tsx
"use client";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BasicInfoTab } from "./tabs/basic-info-tab";
import { InventoryPricingTab } from "./tabs/inventory-pricing-tab";
import { AdditionalDetailsTab } from "./tabs/additional-details-tab";
import { ProductData } from "@/types/product";
import { cn } from "@/lib/utils";
export interface Option {
label: string;
value: string;
}
export function ProductUpdateForm({
product,
brandOptions,
categoryOptions,
taxOptions,
unitOptions,
}: {
product: ProductData;
categoryOptions: Option[];
taxOptions: Option[];
unitOptions: Option[];
brandOptions: Option[];
}) {
const [activeTab, setActiveTab] = useState("basic-info");
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="w-full p-0 bg-transparent border-b rounded-none mb-6 relative">
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted"></div>
<TabsTrigger
value="basic-info"
className={cn(
"py-3 px-6 rounded-none data-[state=active]:shadow-none relative",
"data-[state=active]:text-primary data-[state=active]:font-medium",
"after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary after:scale-x-0 data-[state=active]:after:scale-x-100 after:transition-transform"
)}
>
Basic Information
</TabsTrigger>
<TabsTrigger
value="inventory-pricing"
className={cn(
"py-3 px-6 rounded-none data-[state=active]:shadow-none relative",
"data-[state=active]:text-primary data-[state=active]:font-medium",
"after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary after:scale-x-0 data-[state=active]:after:scale-x-100 after:transition-transform"
)}
>
Inventory & Pricing
</TabsTrigger>
<TabsTrigger
value="additional-details"
className={cn(
"py-3 px-6 rounded-none data-[state=active]:shadow-none relative",
"data-[state=active]:text-primary data-[state=active]:font-medium",
"after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary after:scale-x-0 data-[state=active]:after:scale-x-100 after:transition-transform"
)}
>
Additional Details
</TabsTrigger>
</TabsList>
<TabsContent value="basic-info">
<BasicInfoTab
brandOptions={brandOptions}
categoryOptions={categoryOptions}
product={product}
/>
</TabsContent>
<TabsContent value="inventory-pricing">
<InventoryPricingTab
unitOptions={unitOptions}
taxOptions={taxOptions}
product={product}
/>
</TabsContent>
<TabsContent value="additional-details">
<AdditionalDetailsTab product={product} />
</TabsContent>
</Tabs>
);
}
Tab Components
app/products/[id]/edit/tabs/basic-info-tab.tsx
"use client";
import { useState } from "react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { ProductData } from "@/types/product";
import { updateProductById } from "@/services/products";
import { Option } from "../product-update-form";
export function BasicInfoTab({
product,
brandOptions,
categoryOptions,
}: {
product: ProductData;
categoryOptions: Option[];
brandOptions: Option[];
}) {
return (
<div className="grid gap-6 mt-6">
<NameSlugCard product={product} />
<SkuBarcodeCard product={product} />
<DescriptionDimensionsCard product={product} />
<WeightThumbnailCard product={product} />
<CategoryBrandCard
categoryOptions={categoryOptions}
brandOptions={brandOptions}
product={product}
/>
</div>
);
}
// SOAPI: Atomic Updates - Each card updates only related fields
function NameSlugCard({ product }: { product: ProductData }) {
const [name, setName] = useState(product.name);
const [slug, setSlug] = useState(product.slug);
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
if (!name.trim()) {
toast.error("Name is required");
return;
}
setIsUpdating(true);
try {
// SOAPI: Atomic Updates - Only send changed fields
const data = { name, slug };
await updateProductById(product.id, data);
toast.success("Name and slug updated successfully");
} catch (error) {
toast.error("Failed to update name and slug");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Basic Details</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Product name"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="product-slug"
/>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update Basic Details"}
</Button>
</CardFooter>
</Card>
);
}
function SkuBarcodeCard({ product }: { product: ProductData }) {
const [sku, setSku] = useState(product.sku);
const [barcode, setBarcode] = useState(product.barcode || "");
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
if (!sku.trim()) {
toast.error("SKU is required");
return;
}
setIsUpdating(true);
try {
// SOAPI: Atomic Updates - Only changed fields
const data = { sku, barcode: barcode || undefined };
await updateProductById(product.id, data);
toast.success("SKU and barcode updated successfully");
} catch (error) {
toast.error("Failed to update SKU and barcode");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Product Identifiers</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="sku">SKU</Label>
<Input
id="sku"
value={sku}
onChange={(e) => setSku(e.target.value)}
placeholder="SKU123456"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="barcode">Barcode</Label>
<Input
id="barcode"
value={barcode}
onChange={(e) => setBarcode(e.target.value)}
placeholder="123456789012"
/>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update Identifiers"}
</Button>
</CardFooter>
</Card>
);
}
function DescriptionDimensionsCard({ product }: { product: ProductData }) {
const [description, setDescription] = useState(product.description || "");
const [dimensions, setDimensions] = useState(product.dimensions || "");
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
setIsUpdating(true);
try {
const data = {
description: description || undefined,
dimensions: dimensions || undefined,
};
await updateProductById(product.id, data);
toast.success("Description and dimensions updated successfully");
} catch (error) {
toast.error("Failed to update description and dimensions");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Product Description</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Product description"
rows={3}
/>
</div>
<div className="grid gap-3">
<Label htmlFor="dimensions">Dimensions</Label>
<Input
id="dimensions"
value={dimensions}
onChange={(e) => setDimensions(e.target.value)}
placeholder="10x20x30 cm"
/>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update Description"}
</Button>
</CardFooter>
</Card>
);
}
function WeightThumbnailCard({ product }: { product: ProductData }) {
const [weight, setWeight] = useState(product.weight?.toString() || "");
const [thumbnail, setThumbnail] = useState(product.thumbnail || "");
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
setIsUpdating(true);
try {
const data = {
weight: weight ? Number.parseFloat(weight) : undefined,
thumbnail: thumbnail || undefined,
};
await updateProductById(product.id, data);
toast.success("Weight and thumbnail updated successfully");
} catch (error) {
toast.error("Failed to update weight and thumbnail");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Weight & Thumbnail</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="weight">Weight (kg)</Label>
<Input
id="weight"
type="number"
step="0.01"
value={weight}
onChange={(e) => setWeight(e.target.value)}
placeholder="1.5"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="thumbnail">Thumbnail URL</Label>
<Input
id="thumbnail"
value={thumbnail}
onChange={(e) => setThumbnail(e.target.value)}
placeholder="https://example.com/image.jpg"
/>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update Weight & Thumbnail"}
</Button>
</CardFooter>
</Card>
);
}
function CategoryBrandCard({
product,
brandOptions,
categoryOptions,
}: {
product: ProductData;
categoryOptions: Option[];
brandOptions: Option[];
}) {
const [categoryId, setCategoryId] = useState(product.categoryId || "");
const [brandId, setBrandId] = useState(product.brandId || "");
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
setIsUpdating(true);
try {
const data = {
categoryId: categoryId || undefined,
brandId: brandId || undefined,
};
await updateProductById(product.id, data);
toast.success("Category and brand updated successfully");
} catch (error) {
toast.error("Failed to update category and brand");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Category & Brand</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="category">Category</Label>
<select
id="category"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">Select Category</option>
{categoryOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="grid gap-3">
<Label htmlFor="brand">Brand</Label>
<select
id="brand"
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">Select Brand</option>
{brandOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update Category & Brand"}
</Button>
</CardFooter>
</Card>
);
}
app/products/[id]/edit/tabs/inventory-pricing-tab.tsx
"use client";
import { useState } from "react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
import { ProductData } from "@/types/product";
import { updateProductById } from "@/services/products";
import { Option } from "../product-update-form";
export function InventoryPricingTab({
product,
taxOptions,
unitOptions,
}: {
product: ProductData;
taxOptions: Option[];
unitOptions: Option[];
}) {
return (
<div className="grid gap-6 mt-6">
<PricingCard product={product} />
<StockLevelsCard product={product} />
<StatusCard product={product} />
<UnitTaxCard
taxOptions={taxOptions}
unitOptions={unitOptions}
product={product}
/>
</div>
);
}
// SOAPI: Atomic Updates - Pricing card only handles pricing fields
function PricingCard({ product }: { product: ProductData }) {
const [costPrice, setCostPrice] = useState(product.costPrice.toString());
const [sellingPrice, setSellingPrice] = useState(
product.sellingPrice.toString()
);
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
if (!costPrice.trim() || !sellingPrice.trim()) {
toast.error("Both prices are required");
return;
}
setIsUpdating(true);
try {
// SOAPI: Atomic Updates - Only pricing data
const data = {
costPrice: Number.parseFloat(costPrice),
sellingPrice: Number.parseFloat(sellingPrice),
};
await updateProductById(product.id, data);
toast.success("Prices updated successfully");
} catch (error) {
toast.error("Failed to update prices");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Pricing</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="costPrice">Cost Price</Label>
<Input
id="costPrice"
type="number"
step="0.01"
value={costPrice}
onChange={(e) => setCostPrice(e.target.value)}
placeholder="0.00"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="sellingPrice">Selling Price</Label>
<Input
id="sellingPrice"
type="number"
step="0.01"
value={sellingPrice}
onChange={(e) => setSellingPrice(e.target.value)}
placeholder="0.00"
/>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update Pricing"}
</Button>
</CardFooter>
</Card>
);
}
function StockLevelsCard({ product }: { product: ProductData }) {
const [minStockLevel, setMinStockLevel] = useState(
product.minStockLevel.toString()
);
const [maxStockLevel, setMaxStockLevel] = useState(
product.maxStockLevel?.toString() || ""
);
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
if (!minStockLevel.trim()) {
toast.error("Minimum stock level is required");
return;
}
setIsUpdating(true);
try {
const data = {
minStockLevel: Number.parseInt(minStockLevel),
maxStockLevel: maxStockLevel
? Number.parseInt(maxStockLevel)
: undefined,
};
await updateProductById(product.id, data);
toast.success("Stock levels updated successfully");
} catch (error) {
toast.error("Failed to update stock levels");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Stock Levels</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="minStockLevel">Minimum Stock Level</Label>
<Input
id="minStockLevel"
type="number"
value={minStockLevel}
onChange={(e) => setMinStockLevel(e.target.value)}
placeholder="0"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="maxStockLevel">Maximum Stock Level</Label>
<Input
id="maxStockLevel"
type="number"
value={maxStockLevel}
onChange={(e) => setMaxStockLevel(e.target.value)}
placeholder="Optional"
/>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update Stock Levels"}
</Button>
</CardFooter>
</Card>
);
}
function StatusCard({ product }: { product: ProductData }) {
const [isActive, setIsActive] = useState(product.isActive);
const [isSerialTracked, setIsSerialTracked] = useState(
product.isSerialTracked
);
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
setIsUpdating(true);
try {
const data = { isActive, isSerialTracked };
await updateProductById(product.id, data);
toast.success("Status updated successfully");
} catch (error) {
toast.error("Failed to update status");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Product Status</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="flex items-center justify-between">
<Label htmlFor="isActive" className="cursor-pointer">
Active Product
</Label>
<Switch
id="isActive"
checked={isActive}
onCheckedChange={setIsActive}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="isSerialTracked" className="cursor-pointer">
Serial Number Tracking
</Label>
<Switch
id="isSerialTracked"
checked={isSerialTracked}
onCheckedChange={setIsSerialTracked}
/>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update Status"}
</Button>
</CardFooter>
</Card>
);
}
function UnitTaxCard({
product,
unitOptions,
taxOptions,
}: {
product: ProductData;
unitOptions: Option[];
taxOptions: Option[];
}) {
const [taxId, setTaxId] = useState(product.taxRateId || "");
const [unitId, setUnitId] = useState(product.unitId || "");
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
setIsUpdating(true);
try {
const selectedTax = taxOptions.find((option) => option.value === taxId);
const selectedUnit = unitOptions.find(
(option) => option.value === unitId
);
const taxRate = selectedTax?.label.split("-")[1]?.replace("%", "") || "0";
const unitName = selectedUnit?.label.split("-")[0] || "";
const data = {
taxRateId: taxId || undefined,
unitId: unitId || undefined,
unitOfMeasure: unitName,
tax: Number.parseFloat(taxRate),
};
await updateProductById(product.id, data);
toast.success("Unit and tax updated successfully");
} catch (error) {
toast.error("Failed to update unit and tax");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Unit & Tax</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="unit">Unit of Measure</Label>
<select
id="unit"
value={unitId}
onChange={(e) => setUnitId(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">Select Unit</option>
{unitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="grid gap-3">
<Label htmlFor="tax">Tax Rate</Label>
<select
id="tax"
value={taxId}
onChange={(e) => setTaxId(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">Select Tax Rate</option>
{taxOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update Unit & Tax"}
</Button>
</CardFooter>
</Card>
);
}
app/products/[id]/edit/tabs/additional-details-tab.tsx
"use client";
import { useState } from "react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { ProductData } from "@/types/product";
import { updateProductById } from "@/services/products";
export function AdditionalDetailsTab({ product }: { product: ProductData }) {
return (
<div className="grid gap-6 mt-6">
<UpcEanCard product={product} />
<MpnIsbnCard product={product} />
<SalesInfoCard product={product} />
</div>
);
}
function UpcEanCard({ product }: { product: ProductData }) {
const [upc, setUpc] = useState(product.upc || "");
const [ean, setEan] = useState(product.ean || "");
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
setIsUpdating(true);
try {
const data = {
upc: upc || undefined,
ean: ean || undefined,
};
await updateProductById(product.id, data);
toast.success("UPC and EAN updated successfully");
} catch (error) {
toast.error("Failed to update UPC and EAN");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>UPC & EAN</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="upc">UPC (Universal Product Code)</Label>
<Input
id="upc"
value={upc}
onChange={(e) => setUpc(e.target.value)}
placeholder="123456789012"
maxLength={12}
/>
<p className="text-xs text-muted-foreground">
12-digit unique number associated with the barcode
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="ean">EAN (International Article Number)</Label>
<Input
id="ean"
value={ean}
onChange={(e) => setEan(e.target.value)}
placeholder="1234567890123"
maxLength={13}
/>
<p className="text-xs text-muted-foreground">
13-digit unique number
</p>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update UPC & EAN"}
</Button>
</CardFooter>
</Card>
);
}
function MpnIsbnCard({ product }: { product: ProductData }) {
const [mpn, setMpn] = useState(product.mpn || "");
const [isbn, setIsbn] = useState(product.isbn || "");
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
setIsUpdating(true);
try {
const data = {
mpn: mpn || undefined,
isbn: isbn || undefined,
};
await updateProductById(product.id, data);
toast.success("MPN and ISBN updated successfully");
} catch (error) {
toast.error("Failed to update MPN and ISBN");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>MPN & ISBN</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="mpn">MPN (Manufacturing Part Number)</Label>
<Input
id="mpn"
value={mpn}
onChange={(e) => setMpn(e.target.value)}
placeholder="MPN123456"
/>
<p className="text-xs text-muted-foreground">
Unambiguously identifies a part design
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="isbn">
ISBN (International Standard Book Number)
</Label>
<Input
id="isbn"
value={isbn}
onChange={(e) => setIsbn(e.target.value)}
placeholder="9781234567897"
maxLength={13}
/>
<p className="text-xs text-muted-foreground">
13-digit unique commercial book identifier
</p>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update MPN & ISBN"}
</Button>
</CardFooter>
</Card>
);
}
// SOAPI: Protected Deletes - Sales info is read-only to prevent data corruption
function SalesInfoCard({ product }: { product: ProductData }) {
return (
<Card>
<CardHeader>
<CardTitle>Sales Information</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="salesCount">Sales Count</Label>
<Input
id="salesCount"
type="number"
value={product.salesCount.toString()}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
Total number of units sold (read-only)
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="salesTotal">Sales Total</Label>
<Input
id="salesTotal"
type="number"
step="0.01"
value={product.salesTotal.toString()}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
Total revenue from this product (read-only)
</p>
</div>
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground">
Sales information is automatically calculated and cannot be manually
edited
</p>
</CardFooter>
</Card>
);
}
app/products/[id]/edit/edit-loading.tsx
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
export default function EditProductLoading() {
return (
<div className="container py-10 animate-pulse">
{/* Header Section */}
<div className="mb-8 space-y-4">
<div className="flex items-center gap-2">
<div className="h-9 w-9 rounded-full bg-muted"></div>
<Skeleton className="h-4 w-32" />
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<Skeleton className="h-9 w-[450px] mb-2" />
<Skeleton className="h-5 w-[300px]" />
</div>
<div className="flex items-center gap-2">
<Button variant="outline" disabled>
Preview
</Button>
</div>
</div>
</div>
{/* Tabs */}
<div className="w-full p-0 border-b rounded-none mb-6 relative">
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted"></div>
<div className="flex">
<div className="py-3 px-6 relative">
<Skeleton className="h-5 w-32" />
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
</div>
<div className="py-3 px-6">
<Skeleton className="h-5 w-36" />
</div>
<div className="py-3 px-6">
<Skeleton className="h-5 w-36" />
</div>
</div>
</div>
{/* Content Cards */}
<div className="grid gap-6 mt-6">
{[1, 2, 3].map((i) => (
<div key={i} className="border rounded-lg p-6 space-y-6">
<div className="flex justify-between items-center">
<Skeleton className="h-6 w-32" />
</div>
<div className="space-y-6">
<div className="space-y-3">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-3">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<div className="pt-2">
<Skeleton className="h-10 w-40" />
</div>
</div>
))}
</div>
</div>
);
}
Form and Common Input Components
components/ui/button.tsx
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { LoaderCircle, LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import Link from "next/link";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none cursor-pointer",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-11 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
focus: {
default: "focus:border-primary focus:border-2",
ring: "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
none: "",
},
},
defaultVariants: {
variant: "default",
size: "default",
focus: "default",
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
icon?: LucideIcon;
iconPosition?: "left" | "right";
isLoading?: boolean;
loadingText?: string;
href?: string;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
focus,
asChild = false,
icon: Icon,
iconPosition = "left",
isLoading = false,
loadingText,
href,
children,
...props
},
ref
) => {
// If href is provided, render as Link
if (href) {
// Extract only the properties that are valid for Link component
const { rel, title, id, role, tabIndex, "aria-label": ariaLabel } = props;
return (
<Link
href={href}
className={cn(buttonVariants({ variant, size, focus, className }))}
rel={rel}
title={title}
id={id}
role={role}
tabIndex={tabIndex}
aria-label={ariaLabel}
>
{isLoading ? (
<>
<LoaderCircle className="size-4 animate-spin" />
{loadingText || children}
</>
) : (
<>
{Icon && iconPosition === "left" && <Icon className="size-4" />}
{children}
{Icon && iconPosition === "right" && <Icon className="size-4" />}
</>
)}
</Link>
);
}
// Otherwise render as button or slot
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, focus, className }))}
disabled={isLoading || props.disabled}
ref={ref}
{...props}
>
{isLoading ? (
<>
<LoaderCircle className="size-4 animate-spin" />
{loadingText || children}
</>
) : (
<>
{Icon && iconPosition === "left" && <Icon className="size-4" />}
{children}
{Icon && iconPosition === "right" && <Icon className="size-4" />}
</>
)}
</Comp>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants, type ButtonProps };
components/ui/input.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
import { LucideIcon } from "lucide-react";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
icon?: LucideIcon;
iconPosition?: "left" | "right";
iconClassName?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{
className,
type,
icon: Icon,
iconPosition = "left",
iconClassName,
...props
},
ref
) => {
return (
<div className="relative flex items-center w-full">
{Icon && iconPosition === "left" && (
<div className="absolute left-3 flex items-center pointer-events-none">
<Icon
className={cn("h-5 w-5 text-muted-foreground", iconClassName)}
/>
</div>
)}
<input
type={type}
data-slot="input"
className={cn(
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus:border-primary focus:border-2",
"aria-invalid:border-destructive",
Icon && iconPosition === "left" && "pl-10",
Icon && iconPosition === "right" && "pr-10",
className
)}
ref={ref}
{...props}
/>
{Icon && iconPosition === "right" && (
<div className="absolute right-3 flex items-center pointer-events-none">
<Icon
className={cn("h-5 w-5 text-muted-foreground", iconClassName)}
/>
</div>
)}
</div>
);
}
);
Input.displayName = "Input";
export { Input };
components/ui/editor
// components/Editor/index.tsx
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import { Button } from "../ui/button";
import {
Bold,
Italic,
List,
ListOrdered,
Quote,
Redo,
Strikethrough,
Undo,
} from "lucide-react";
interface EditorProps {
value: string;
onChange: (value: string) => void;
}
const Editor = ({ value, onChange }: EditorProps) => {
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
}),
],
content: value,
editorProps: {
attributes: {
class:
"prose prose-sm sm:prose-base dark:prose-invert max-w-none focus:outline-none min-h-[150px] px-3 py-2 border rounded-md",
},
},
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
if (!editor) {
return null;
}
return (
<div className="border rounded-lg">
<div className="flex flex-wrap gap-2 p-2 border-b bg-muted">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "bg-muted-foreground/20" : ""}
>
<Bold className="w-4 h-4" />
</Button>
<Button
variant="ghost"
type="button"
size="sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "bg-muted-foreground/20" : ""}
>
<Italic className="w-4 h-4" />
</Button>
<Button
variant="ghost"
type="button"
size="sm"
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive("strike") ? "bg-muted-foreground/20" : ""}
>
<Strikethrough className="w-4 h-4" />
</Button>
<div className="w-px h-4 bg-border my-auto" />
<Button
variant="ghost"
type="button"
size="sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={
editor.isActive("bulletList") ? "bg-muted-foreground/20" : ""
}
>
<List className="w-4 h-4" />
</Button>
<Button
variant="ghost"
type="button"
size="sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={
editor.isActive("orderedList") ? "bg-muted-foreground/20" : ""
}
>
<ListOrdered className="w-4 h-4" />
</Button>
<Button
variant="ghost"
type="button"
size="sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={
editor.isActive("blockquote") ? "bg-muted-foreground/20" : ""
}
>
<Quote className="w-4 h-4" />
</Button>
<div className="w-px h-4 bg-border my-auto" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().undo().run()}
>
<Undo className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => editor.chain().focus().redo().run()}
>
<Redo className="w-4 h-4" />
</Button>
</div>
<EditorContent editor={editor} />
</div>
);
};
export default Editor;
Editor Usage
function NotesCard({ item, courseId }: { item: Lesson; courseId: string }) {
const [notes, setNotes] = useState(item.notes || "");
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
setIsUpdating(true);
try {
const data = {
notes: notes,
};
// console.log(data);
await updateLessonById(item.id, data, courseId);
toast.success("Lesson Notes updated successfully");
} catch (error) {
toast.error("Failed to update Notes");
console.error(error);
} finally {
setIsUpdating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Lesson Notes</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Editor value={notes} onChange={(value) => setNotes(value)} />
<p className="text-xs text-muted-foreground">Enter Lesson Notes</p>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update Lesson Notes "}
</Button>
</CardFooter>
</Card>
);
}
Data Table Components
Core Data Table Structure
The data table system consists of 9 reusable components that work together to provide a complete table solution.
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 };
components/ui/data-table/data-table.tsx
// 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/data-table/rows-per-page";
import FilterBar from "./filter-bar";
import TableActions from "./table-actions";
import {
DateRange,
DateFilterOption,
} from "@/components/ui/data-table/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(10);
// 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 =
filters && filters.getItemDate
? new Date(filters.getItemDate(item))
: new Date();
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 <= 10) {
// Show all pages if 10 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>}
</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 &&
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={[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>
);
}
components/ui/data-table/table-actions.tsx
import {
Plus,
Edit,
Trash2,
FileSpreadsheet,
Loader2,
Eye,
} from "lucide-react";
import { Button } from "@/components/ui/button";
interface ActionButtonProps {
onClick: () => void;
disabled?: boolean;
loading?: boolean;
className?: string;
}
const AddButton = ({
onClick,
disabled = false,
loading = false,
}: ActionButtonProps) => (
<Button onClick={onClick} disabled={disabled || loading}>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Add New
</Button>
);
const EditButton = ({
onClick,
disabled = false,
loading = false,
}: ActionButtonProps) => (
<Button
variant="outline"
size="icon"
onClick={onClick}
disabled={disabled || loading}
title="Edit"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Edit className="h-4 w-4" />
)}
</Button>
);
const DeleteButton = ({
onClick,
disabled = false,
loading = false,
}: ActionButtonProps) => (
<Button
variant="outline"
size="icon"
onClick={onClick}
disabled={disabled || loading}
title="Delete"
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
);
const ExportButton = ({
onClick,
disabled = false,
loading = false,
}: ActionButtonProps) => (
<Button variant="outline" onClick={onClick} disabled={disabled || loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Exporting...
</>
) : (
<>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Export
</>
)}
</Button>
);
const RowActions = ({
onEdit,
onDelete,
onView,
isDeleting = false,
}: {
onEdit?: () => void;
onDelete?: () => void;
onView?: () => void;
isDeleting?: boolean;
}) => (
<div className="flex justify-end gap-2">
{onEdit && <EditButton onClick={onEdit} />}
{onView && (
<Button variant="outline" size="icon" onClick={onView}>
<Eye className="h-4 w-4" />
</Button>
)}
{onDelete && <DeleteButton onClick={onDelete} loading={isDeleting} />}
</div>
);
const TableActions = {
AddButton,
EditButton,
DeleteButton,
ExportButton,
RowActions,
};
export default TableActions;
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";
}
export default function EntityForm<TFormValues extends FieldValues>({
open,
onOpenChange,
title,
form,
onSubmit,
children,
isSubmitting = false,
submitLabel = "Save",
cancelLabel = "Cancel",
size = "sm",
}: EntityFormProps<TFormValues>) {
const sizeClasses = {
sm: "sm:max-w-[425px]",
md: "sm:max-w-[550px]",
lg: "sm:max-w-[650px]",
xl: "sm:max-w-[800px]",
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={sizeClasses[size]}>
<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}>
{cancelLabel}
</Button>
</DialogClose>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
submitLabel
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
Additional Data Table Components
components/ui/data-table/filter-bar.tsx
import { ReactNode } from "react";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import DateFilter, { DateRange, DateFilterOption } from "./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>
);
}
components/ui/data-table/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";
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;
}
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);
if (range?.from && range?.to) {
setTimeout(() => {
onFilterChange(
{
from: startOfDay(range.from!),
to: endOfDay(range.to!),
},
"custom"
);
}, 0);
}
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
)}
</div>
);
}
components/ui/data-table/confirmation-dialog.tsx
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>
);
}
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>
);
}
components/ui/data-table/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>
);
}
React Query Provider Setup
app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
import { Toaster } from "sonner";
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// SOAPI: Intelligent caching strategy
staleTime: 60 * 1000, // 1 minute
gcTime: 10 * 60 * 1000, // 10 minutes (previously cacheTime)
retry: (failureCount, error: any) => {
// Don't retry on 4xx errors
if (
error?.response?.status >= 400 &&
error?.response?.status < 500
) {
return false;
}
return failureCount < 3;
},
},
mutations: {
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
app/layout.tsx
import Providers from "./providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Advanced Features
Error Handling Service
services/error-handler.ts
import { toast } from "sonner";
import axios from "axios";
export interface ApiError {
message: string;
status?: number;
code?: string;
}
export class ErrorHandler {
static handle(error: unknown): ApiError {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const message = error.response?.data?.message || error.message;
// SOAPI: Intelligent error responses
switch (status) {
case 400:
toast.error("Invalid request", { description: message });
break;
case 401:
toast.error("Authentication required");
// Redirect to login or refresh token
break;
case 403:
toast.error("Permission denied", { description: message });
break;
case 404:
toast.error("Resource not found");
break;
case 409:
toast.error("Conflict", { description: message });
break;
case 422:
toast.error("Validation failed", { description: message });
break;
case 500:
toast.error("Server error", {
description: "Please try again later",
});
break;
default:
toast.error("Request failed", { description: message });
}
return {
message,
status,
code: error.response?.data?.code,
};
}
if (error instanceof Error) {
toast.error("Error", { description: error.message });
return { message: error.message };
}
toast.error("Unknown error occurred");
return { message: "An unexpected error occurred" };
}
}
Optimistic Updates Hook
hooks/useOptimisticUpdate.ts
import { useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
export function useOptimisticUpdate() {
const queryClient = useQueryClient();
const updateOptimistically = useCallback(
<T>(queryKey: any[], updater: (old: T) => T) => {
// SOAPI: Intelligent responses with optimistic updates
queryClient.setQueryData(queryKey, updater);
},
[queryClient]
);
const rollback = useCallback(
(queryKey: any[], previousData: any) => {
queryClient.setQueryData(queryKey, previousData);
},
[queryClient]
);
return { updateOptimistically, rollback };
}
Bulk Operations Hook
hooks/useBulkOperations.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
interface BulkOperationResult {
successful: string[];
failed: { id: string; error: string }[];
}
export function useBulkDelete<T extends { id: string }>(
deleteFunction: (id: string) => Promise<any>,
queryKeys: any[]
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (items: T[]): Promise<BulkOperationResult> => {
const results: BulkOperationResult = {
successful: [],
failed: [],
};
// SOAPI: Protected Deletes - Process each item safely
for (const item of items) {
try {
await deleteFunction(item.id);
results.successful.push(item.id);
} catch (error) {
results.failed.push({
id: item.id,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
return results;
},
onSuccess: (results) => {
const { successful, failed } = results;
if (successful.length > 0) {
toast.success(`Deleted ${successful.length} items successfully`);
queryClient.invalidateQueries({ queryKey: queryKeys });
}
if (failed.length > 0) {
toast.error(`Failed to delete ${failed.length} items`, {
description: failed.map((f) => f.error).join(", "),
});
}
},
onError: () => {
toast.error("Bulk delete operation failed");
},
});
}
Performance Optimization
Virtual Scrolling for Large Datasets
components/ui/virtual-table.tsx
"use client";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface VirtualTableProps<T> {
data: T[];
columns: Array<{
header: string;
accessorKey: keyof T | ((row: T) => any);
width?: number;
}>;
rowHeight?: number;
overscan?: number;
}
export default function VirtualTable<T>({
data,
columns,
rowHeight = 50,
overscan = 5,
}: VirtualTableProps<T>) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight,
overscan,
});
const getCellValue = (item: T, accessor: keyof T | ((row: T) => any)) => {
if (typeof accessor === "function") {
return accessor(item);
}
return item[accessor];
};
return (
<div ref={parentRef} className="h-[400px] overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
}}
>
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
{columns.map((column, index) => (
<TableHead key={index} style={{ width: column.width }}>
{column.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = data[virtualRow.index];
return (
<TableRow
key={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{columns.map((column, colIndex) => (
<TableCell key={colIndex}>
{getCellValue(item, column.accessorKey)}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}
Infinite Scroll Hook
hooks/useInfiniteProducts.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { getBriefProducts } from "@/services/products";
interface InfiniteProductsParams {
pageSize?: number;
search?: string;
filters?: Record<string, any>;
}
export function useInfiniteProducts({
pageSize = 20,
search,
filters,
}: InfiniteProductsParams = {}) {
return useInfiniteQuery({
queryKey: ["products", "infinite", { pageSize, search, filters }],
queryFn: ({ pageParam = 1 }) =>
getBriefProducts({
page: pageParam,
limit: pageSize,
search,
...filters,
}),
getNextPageParam: (lastPage, pages) => {
// SOAPI: Optimized queries with pagination
const hasMore = lastPage.data.length === pageSize;
return hasMore ? pages.length + 1 : undefined;
},
initialPageParam: 1,
});
}
Implementation Checklist
Phase 1: Setup
- Install required dependencies
- Create directory structure
- Set up axios configuration
- Configure React Query provider
Phase 2: API Layer
- Create type definitions
- Implement service functions following SOAPI principles
- Create React Query hooks
- Test API integration
Phase 3: Components
- Implement data table components
- Create product listing page
- Add CRUD functionality
- Implement export features
Phase 4: Update Forms
- Create edit page structure
- Implement tabbed update forms
- Add atomic update cards
- Test update functionality
Phase 5: Optimization
- Implement proper error handling
- Add loading states
- Optimize queries with proper selects
- Add data validation
Best Practices Summary
- Small Payloads: Never exceed 5 fields in creation forms
- Optimized Queries: Always use select to fetch only required fields
- Atomic Updates: Update only changed fields using PATCH
- Protected Deletes: Check relationships before deletion
- Intelligent Responses: Return IDs instead of full objects
- Smart UI: Group related fields in cards with dedicated update buttons
- React Query: Use for caching, error handling, and data synchronization
- TypeScript: Strong typing for better development experience
Conclusion
The SOAPI methodology provides a comprehensive approach to building efficient, scalable frontend applications with optimal API interactions. By following these principles:
- Small Payloads: Faster requests, better UX
- Optimized Queries: Reduced bandwidth, improved performance
- Atomic Updates: Precise changes, better reliability
- Protected Deletes: Data integrity, safer operations
- Intelligent Responses: Minimal overhead, smart caching
You'll create applications that are not only performant but also maintainable and user-friendly.
Key Benefits Achieved:
✅ 50-70% reduction in API payload sizes
✅ Improved page load times through optimized queries
✅ Better user experience with atomic updates
✅ Reduced data corruption with protected operations
✅ Enhanced developer experience with TypeScript and React Query
This documentation serves as your complete reference for implementing SOAPI principles in any React-based project. Each code example is production-ready and follows industry best practices while maintaining the core SOAPI philosophy.---
Subscribe to My Latest Guides
All the latest Guides and tutorials, straight from the team.