Loading
Create a full-stack inventory system with product management, stock tracking, low-stock alerts, barcode lookup, and reporting.
Every business that sells physical products needs inventory management. In this tutorial, you will build a full-stack inventory management system using Next.js and PostgreSQL that handles products and categories, stock level tracking with transaction history, low-stock alerts, barcode-based product lookup, and reporting with export capabilities.
This is a practical, business-grade application. You will learn database schema design for inventory tracking, server-side rendering with Next.js App Router, form handling with server actions, and how to build dashboards that surface actionable insights from data.
The project uses PostgreSQL as the database, accessed through Prisma ORM. It runs identically on macOS, Windows, and Linux with Docker for the database or a cloud PostgreSQL instance.
Configure the database connection in .env:
DATABASE_URL="postgresql://postgres:password@localhost:5432/inventory?schema=public"Start PostgreSQL locally with Docker (or use any PostgreSQL instance):
Update prisma/schema.prisma:
Run the migration:
Create src/lib/db.ts:
Create src/lib/validation.ts:
Create src/app/actions.ts:
Create src/app/dashboard/page.tsx:
Create src/app/products/page.tsx:
Create src/app/products/new/page.tsx:
Create src/app/products/[id]/page.tsx:
Create src/app/scan/page.tsx as a client component for barcode input:
Create src/app/reports/page.tsx:
Create src/app/reports/ExportButton.tsx:
Run the application:
You now have a full-stack inventory management system with a dashboard showing real-time statistics and low-stock alerts, full CRUD for products with search and filtering, stock transactions with audit history, barcode-based product lookup, category reporting with CSV export, and Zod validation on all inputs. The system handles concurrent stock updates safely using database transactions and validates every input on the server side regardless of client-side checks.
npx create-next-app@latest inventory-system --typescript --tailwind --app --eslint
cd inventory-system
npm install prisma @prisma/client zod
npm install -D @types/node
npx prisma initdocker run --name inventory-db -e POSTGRES_PASSWORD=password -e POSTGRES_DB=inventory -p 5432:5432 -d postgres:16npx prisma migrate dev --name initimport { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}import { z } from "zod";
export const productSchema = z.object({
name: z.string().min(1, "Name is required").max(200),
sku: z.string().min(1, "SKU is required").max(50),
barcode: z.string().max(50).optional().or(z.literal("")),
description: z.string().max(1000).optional().or(z.literal("")),
categoryId: z.string().min(1, "Category is required"),
price: z.coerce.number().min(0, "Price must be positive"),
cost: z.coerce.number().min(0, "Cost must be positive"),
quantity: z.coerce.number().int().min(0),
minStock: z.coerce.number().int().min(0),
location: z.string().max(100).optional().or(z.literal("")),
});
export const stockTransactionSchema = z.object({
productId: z.string().min(1),
type: z.enum(["in", "out", "adjustment"]),
quantity: z.coerce.number().int().positive("Quantity must be positive"),
reason: z.string().max(500).optional().or(z.literal("")),
});
export type ProductInput = z.infer<typeof productSchema>;
export type StockTransactionInput = z.infer<typeof stockTransactionSchema>;"use server";
import { prisma } from "@/lib/db";
import { productSchema, stockTransactionSchema } from "@/lib/validation";
import { revalidatePath } from "next/cache";
interface ActionResult {
success: boolean;
error?: string;
}
export async function createProduct(formData: FormData): Promise<ActionResult> {
const raw = Object.fromEntries(formData.entries());
const parsed = productSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, error: parsed.error.errors[0].message };
}
try {
await prisma.product.create({
data: {
...parsed.data,
barcode: parsed.data.barcode || null,
description: parsed.data.description || null,
location: parsed.data.location || null,
},
});
revalidatePath("/products");
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create product";
if (message.includes("Unique constraint")) {
return { success: false, error: "A product with this SKU or barcode already exists." };
}
return { success: false, error: message };
}
}
export async function updateStock(formData: FormData): Promise<ActionResult> {
const raw = Object.fromEntries(formData.entries());
const parsed = stockTransactionSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, error: parsed.error.errors[0].message };
}
try {
await prisma.$transaction(async (tx) => {
const product = await tx.product.findUniqueOrThrow({
where: { id: parsed.data.productId },
});
let newQuantity: number;
if (parsed.data.type === "in") {
newQuantity = product.quantity + parsed.data.quantity;
} else if (parsed.data.type === "out") {
newQuantity = product.quantity - parsed.data.quantity;
if (newQuantity < 0) {
throw new Error("Insufficient stock");
}
} else {
newQuantity = parsed.data.quantity;
}
await tx.product.update({
where: { id: parsed.data.productId },
data: { quantity: newQuantity },
});
await tx.stockTransaction.create({
data: {
productId: parsed.data.productId,
type: parsed.data.type,
quantity: parsed.data.quantity,
reason: parsed.data.reason || null,
},
});
});
revalidatePath("/products");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update stock";
return { success: false, error: message };
}
}
export async function lookupBarcode(barcode: string): Promise<{
found: boolean;
product?: { id: string; name: string; sku: string; quantity: number };
}> {
try {
const product = await prisma.product.findUnique({
where: { barcode },
select: { id: true, name: true, sku: true, quantity: true },
});
if (!product) return { found: false };
return { found: true, product };
} catch {
return { found: false };
}
}import { prisma } from "@/lib/db";
interface DashboardStats {
totalProducts: number;
totalValue: number;
lowStockCount: number;
outOfStockCount: number;
}
async function getStats(): Promise<DashboardStats> {
const [totalProducts, products, lowStock, outOfStock] = await Promise.all([
prisma.product.count(),
prisma.product.findMany({ select: { quantity: true, cost: true } }),
prisma.product.count({ where: { quantity: { gt: 0, lte: prisma.product.fields.minStock } } }),
prisma.product.count({ where: { quantity: 0 } }),
]);
const totalValue = products.reduce(
(sum, p) => sum + p.quantity * Number(p.cost),
0
);
return { totalProducts, totalValue, lowStockCount: lowStock, outOfStockCount: outOfStock };
}
async function getLowStockProducts(): Promise<Array<{
id: string;
name: string;
sku: string;
quantity: number;
minStock: number;
category: { name: string; color: string };
}>> {
return prisma.product.findMany({
where: {
OR: [
{ quantity: 0 },
{ quantity: { lte: 10 } },
],
},
select: {
id: true,
name: true,
sku: true,
quantity: true,
minStock: true,
category: { select: { name: true, color: true } },
},
orderBy: { quantity: "asc" },
take: 20,
});
}
export default async function DashboardPage(): Promise<React.ReactElement> {
const [stats, lowStock] = await Promise.all([getStats(), getLowStockProducts()]);
const statCards = [
{ label: "Total Products", value: stats.totalProducts.toLocaleString(), color: "bg-blue-500" },
{ label: "Inventory Value", value: `$${stats.totalValue.toLocaleString()}`, color: "bg-green-500" },
{ label: "Low Stock", value: stats.lowStockCount.toLocaleString(), color: "bg-yellow-500" },
{ label: "Out of Stock", value: stats.outOfStockCount.toLocaleString(), color: "bg-red-500" },
];
return (
<div className="p-8 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Dashboard</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
{statCards.map((stat) => (
<div key={stat.label} className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<div className={`w-3 h-3 rounded-full ${stat.color} mb-3`} />
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="text-2xl font-bold mt-1">{stat.value}</p>
</div>
))}
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
<div className="p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold">Low Stock Alerts</h2>
</div>
<div className="divide-y divide-gray-100">
{lowStock.map((product) => (
<div key={product.id} className="flex items-center justify-between p-4 px-6">
<div>
<p className="font-medium">{product.name}</p>
<p className="text-sm text-gray-500">{product.sku} · {product.category.name}</p>
</div>
<div className="text-right">
<p className={`text-lg font-bold ${product.quantity === 0 ? "text-red-500" : "text-yellow-500"}`}>
{product.quantity}
</p>
<p className="text-xs text-gray-400">min: {product.minStock}</p>
</div>
</div>
))}
{lowStock.length === 0 && (
<p className="p-6 text-gray-400 text-center">All products are well stocked.</p>
)}
</div>
</div>
</div>
);
}import Link from "next/link";
import { prisma } from "@/lib/db";
interface ProductsPageProps {
searchParams: Promise<{ q?: string; category?: string }>;
}
export default async function ProductsPage({ searchParams }: ProductsPageProps): Promise<React.ReactElement> {
const params = await searchParams;
const query = params.q || "";
const categoryFilter = params.category || "";
const products = await prisma.product.findMany({
where: {
AND: [
query
? {
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ sku: { contains: query, mode: "insensitive" } },
{ barcode: { contains: query, mode: "insensitive" } },
],
}
: {},
categoryFilter ? { categoryId: categoryFilter } : {},
],
},
include: { category: true },
orderBy: { updatedAt: "desc" },
take: 50,
});
const categories = await prisma.category.findMany({ orderBy: { name: "asc" } });
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Products</h1>
<Link
href="/products/new"
className="bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-600"
>
Add Product
</Link>
</div>
<form className="flex gap-4 mb-6">
<input
name="q"
defaultValue={query}
placeholder="Search by name, SKU, or barcode..."
className="flex-1 border border-gray-300 rounded-lg px-4 py-2 text-sm"
/>
<select
name="category"
defaultValue={categoryFilter}
className="border border-gray-300 rounded-lg px-4 py-2 text-sm"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<button type="submit" className="bg-gray-100 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-200">
Filter
</button>
</form>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left p-4 text-xs font-semibold text-gray-500 uppercase">Product</th>
<th className="text-left p-4 text-xs font-semibold text-gray-500 uppercase">SKU</th>
<th className="text-left p-4 text-xs font-semibold text-gray-500 uppercase">Category</th>
<th className="text-right p-4 text-xs font-semibold text-gray-500 uppercase">Price</th>
<th className="text-right p-4 text-xs font-semibold text-gray-500 uppercase">Stock</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{products.map((product) => (
<tr key={product.id} className="hover:bg-gray-50">
<td className="p-4">
<Link href={`/products/${product.id}`} className="font-medium text-indigo-600 hover:underline">
{product.name}
</Link>
</td>
<td className="p-4 text-sm text-gray-500 font-mono">{product.sku}</td>
<td className="p-4">
<span
className="text-xs font-medium px-2 py-1 rounded-full"
style={{ backgroundColor: `${product.category.color}20`, color: product.category.color }}
>
{product.category.name}
</span>
</td>
<td className="p-4 text-right text-sm">${Number(product.price).toFixed(2)}</td>
<td className="p-4 text-right">
<span className={`font-bold ${product.quantity <= product.minStock ? "text-red-500" : "text-green-600"}`}>
{product.quantity}
</span>
</td>
</tr>
))}
</tbody>
</table>
{products.length === 0 && (
<p className="p-8 text-center text-gray-400">No products found.</p>
)}
</div>
</div>
);
}import { prisma } from "@/lib/db";
import { createProduct } from "@/app/actions";
import { redirect } from "next/navigation";
export default async function NewProductPage(): Promise<React.ReactElement> {
const categories = await prisma.category.findMany({ orderBy: { name: "asc" } });
async function handleSubmit(formData: FormData): Promise<void> {
"use server";
const result = await createProduct(formData);
if (result.success) {
redirect("/products");
}
}
return (
<div className="p-8 max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Add Product</h1>
<form action={handleSubmit} className="space-y-6 bg-white p-8 rounded-xl border border-gray-200 shadow-sm">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Product Name</label>
<input name="name" required className="w-full border border-gray-300 rounded-lg px-4 py-2" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">SKU</label>
<input name="sku" required className="w-full border border-gray-300 rounded-lg px-4 py-2" />
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Barcode</label>
<input name="barcode" className="w-full border border-gray-300 rounded-lg px-4 py-2" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select name="categoryId" required className="w-full border border-gray-300 rounded-lg px-4 py-2">
<option value="">Select category</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea name="description" rows={3} className="w-full border border-gray-300 rounded-lg px-4 py-2" />
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Price ($)</label>
<input name="price" type="number" step="0.01" min="0" required className="w-full border border-gray-300 rounded-lg px-4 py-2" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Cost ($)</label>
<input name="cost" type="number" step="0.01" min="0" required className="w-full border border-gray-300 rounded-lg px-4 py-2" />
</div>
</div>
<div className="grid grid-cols-3 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Initial Quantity</label>
<input name="quantity" type="number" min="0" defaultValue="0" className="w-full border border-gray-300 rounded-lg px-4 py-2" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Min Stock Level</label>
<input name="minStock" type="number" min="0" defaultValue="10" className="w-full border border-gray-300 rounded-lg px-4 py-2" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Location</label>
<input name="location" placeholder="e.g., Shelf A3" className="w-full border border-gray-300 rounded-lg px-4 py-2" />
</div>
</div>
<button type="submit" className="w-full bg-indigo-500 text-white py-3 rounded-lg font-semibold hover:bg-indigo-600">
Add Product
</button>
</form>
</div>
);
}import { prisma } from "@/lib/db";
import { updateStock } from "@/app/actions";
import { notFound } from "next/navigation";
interface ProductPageProps {
params: Promise<{ id: string }>;
}
export default async function ProductPage({ params }: ProductPageProps): Promise<React.ReactElement> {
const { id } = await params;
const product = await prisma.product.findUnique({
where: { id },
include: {
category: true,
transactions: { orderBy: { createdAt: "desc" }, take: 20 },
},
});
if (!product) notFound();
return (
<div className="p-8 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-2">{product.name}</h1>
<p className="text-gray-500 mb-8">{product.sku} · {product.category.name}</p>
<div className="grid grid-cols-2 gap-8 mb-10">
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Stock Level</h2>
<p className={`text-5xl font-bold ${product.quantity <= product.minStock ? "text-red-500" : "text-green-600"}`}>
{product.quantity}
</p>
<p className="text-sm text-gray-400 mt-2">Minimum: {product.minStock}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Update Stock</h2>
<form action={updateStock} className="space-y-3">
<input type="hidden" name="productId" value={product.id} />
<select name="type" className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
<option value="in">Stock In</option>
<option value="out">Stock Out</option>
<option value="adjustment">Adjustment</option>
</select>
<input name="quantity" type="number" min="1" placeholder="Quantity" required className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
<input name="reason" placeholder="Reason (optional)" className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
<button type="submit" className="w-full bg-indigo-500 text-white py-2 rounded-lg text-sm font-semibold">
Update
</button>
</form>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
<div className="p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold">Transaction History</h2>
</div>
<div className="divide-y divide-gray-100">
{product.transactions.map((tx) => (
<div key={tx.id} className="flex items-center justify-between p-4 px-6">
<div>
<span className={`text-xs font-bold uppercase px-2 py-0.5 rounded ${
tx.type === "in" ? "bg-green-100 text-green-700" :
tx.type === "out" ? "bg-red-100 text-red-700" :
"bg-blue-100 text-blue-700"
}`}>
{tx.type}
</span>
<span className="ml-3 text-sm text-gray-600">{tx.reason || "No reason"}</span>
</div>
<div className="text-right">
<p className="font-bold">{tx.type === "out" ? "-" : "+"}{tx.quantity}</p>
<p className="text-xs text-gray-400">{new Date(tx.createdAt).toLocaleDateString()}</p>
</div>
</div>
))}
{product.transactions.length === 0 && (
<p className="p-6 text-center text-gray-400">No transactions yet.</p>
)}
</div>
</div>
</div>
);
}"use client";
import React, { useState } from "react";
import { lookupBarcode } from "@/app/actions";
interface ScanResult {
found: boolean;
product?: { id: string; name: string; sku: string; quantity: number };
}
export default function ScanPage(): React.ReactElement {
const [barcode, setBarcode] = useState<string>("");
const [result, setResult] = useState<ScanResult | null>(null);
const [isSearching, setIsSearching] = useState<boolean>(false);
async function handleScan(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (!barcode.trim()) return;
setIsSearching(true);
try {
const data = await lookupBarcode(barcode.trim());
setResult(data);
} catch {
setResult({ found: false });
} finally {
setIsSearching(false);
}
}
return (
<div className="p-8 max-w-xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Barcode Lookup</h1>
<form onSubmit={(e) => void handleScan(e)} className="flex gap-3 mb-8">
<input
value={barcode}
onChange={(e) => setBarcode(e.target.value)}
placeholder="Scan or enter barcode..."
autoFocus
className="flex-1 border border-gray-300 rounded-lg px-4 py-3 text-lg font-mono"
/>
<button
type="submit"
disabled={isSearching}
className="bg-indigo-500 text-white px-6 rounded-lg font-semibold hover:bg-indigo-600 disabled:opacity-50"
>
{isSearching ? "..." : "Lookup"}
</button>
</form>
{result && (
<div className={`rounded-xl border p-6 ${result.found ? "border-green-200 bg-green-50" : "border-red-200 bg-red-50"}`}>
{result.found && result.product ? (
<>
<h2 className="text-xl font-bold text-green-800">{result.product.name}</h2>
<p className="text-green-700 mt-1">SKU: {result.product.sku}</p>
<p className="text-green-700">Stock: {result.product.quantity} units</p>
<a
href={`/products/${result.product.id}`}
className="inline-block mt-4 text-sm text-indigo-600 font-semibold hover:underline"
>
View Product Details
</a>
</>
) : (
<p className="text-red-700 font-medium">No product found for barcode: {barcode}</p>
)}
</div>
)}
</div>
);
}import { prisma } from "@/lib/db";
import { ExportButton } from "./ExportButton";
async function getReportData(): Promise<{
totalProducts: number;
totalValue: number;
categorySummary: Array<{ name: string; count: number; value: number }>;
recentTransactions: Array<{ product: string; type: string; quantity: number; date: string }>;
}> {
const products = await prisma.product.findMany({
include: { category: true },
});
const categorySummary = new Map<string, { count: number; value: number }>();
let totalValue = 0;
for (const product of products) {
const value = product.quantity * Number(product.cost);
totalValue += value;
const existing = categorySummary.get(product.category.name) || { count: 0, value: 0 };
categorySummary.set(product.category.name, {
count: existing.count + 1,
value: existing.value + value,
});
}
const transactions = await prisma.stockTransaction.findMany({
include: { product: { select: { name: true } } },
orderBy: { createdAt: "desc" },
take: 50,
});
return {
totalProducts: products.length,
totalValue,
categorySummary: Array.from(categorySummary.entries()).map(([name, data]) => ({
name,
...data,
})),
recentTransactions: transactions.map((tx) => ({
product: tx.product.name,
type: tx.type,
quantity: tx.quantity,
date: tx.createdAt.toISOString(),
})),
};
}
export default async function ReportsPage(): Promise<React.ReactElement> {
const report = await getReportData();
return (
<div className="p-8 max-w-5xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Reports</h1>
<ExportButton data={report.recentTransactions} />
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-8">
<div className="p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold">Category Breakdown</h2>
</div>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left p-4 text-xs font-semibold text-gray-500 uppercase">Category</th>
<th className="text-right p-4 text-xs font-semibold text-gray-500 uppercase">Products</th>
<th className="text-right p-4 text-xs font-semibold text-gray-500 uppercase">Inventory Value</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{report.categorySummary.map((cat) => (
<tr key={cat.name}>
<td className="p-4 font-medium">{cat.name}</td>
<td className="p-4 text-right">{cat.count}</td>
<td className="p-4 text-right font-mono">${cat.value.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}"use client";
import React from "react";
interface ExportButtonProps {
data: Array<{ product: string; type: string; quantity: number; date: string }>;
}
export function ExportButton({ data }: ExportButtonProps): React.ReactElement {
function handleExport(): void {
const header = "Product,Type,Quantity,Date\n";
const rows = data.map((row) =>
`"${row.product}","${row.type}",${row.quantity},"${row.date}"`
).join("\n");
const csv = header + rows;
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `inventory-report-${Date.now()}.csv`;
link.click();
URL.revokeObjectURL(url);
}
return (
<button onClick={handleExport} className="bg-gray-100 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-200">
Export CSV
</button>
);
}npm run devgenerator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Category {
id String @id @default(cuid())
name String @unique
color String @default("#6366f1")
products Product[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Product {
id String @id @default(cuid())
name String
sku String @unique
barcode String? @unique
description String?
category Category @relation(fields: [categoryId], references: [id])
categoryId String
price Decimal @db.Decimal(10, 2)
cost Decimal @db.Decimal(10, 2)
quantity Int @default(0)
minStock Int @default(10)
location String?
transactions StockTransaction[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([categoryId])
@@index([barcode])
}
model StockTransaction {
id String @id @default(cuid())
product Product @relation(fields: [productId], references: [id])
productId String
type String // "in" | "out" | "adjustment"
quantity Int
reason String?
createdAt DateTime @default(now())
@@index([productId])
@@index([createdAt])
}