Inventory & Sales App with Next.js

Build a full-stack inventory app with forms, a database, and a live dashboard.

Introduction

30 Second Summary

Every business needs to know what it has, what it sold, and how much money it made. Tracking inventory and sales by hand gets messy fast, especially when you need real-time answers.

In this project, you will build a full-stack Inventory & Sales Management app that stores procurement and sales records in a database, then computes live analytics like total revenue and profit on a visual dashboard.

What You'll Build

A complete inventory management application with data entry forms and a live analytics dashboard, powered by Next.js, Prisma, and SQLite.

By the end of this project, you'll have:

  • A Procurement Form that saves incoming stock records (item details, quantities, costs, supplier info) to a relational database.
  • A Sales Form that records outgoing orders with selling prices and customer details.
  • An Analytics Dashboard displaying total items sold, total revenue, total profit, and current stock levels with interactive bar charts.
  • Secret Mission: A low-stock alert system that highlights items with fewer than 5 units remaining in red on the dashboard.

Are there any prerequisites?

Basic familiarity with JavaScript/TypeScript and React components is helpful. No prior Next.js or database experience is required.

Set Up Your Development Environment

Every full-stack app needs a foundation. Before you can build forms or query a database, you need a project scaffold with the right tools installed.

In this step, you will create a Next.js 16 project, then install Prisma (your database toolkit) and Recharts (your charting library). By the end, your project folder will be ready for real development.

In this step, get ready to:
  • Verify Node.js is installed and ready.
  • Scaffold a Next.js 16 project with App Router and TypeScript.
  • Install Prisma, SQLite, and Recharts dependencies.
Confirm Node.js is installed

Next.js and Prisma both require Node.js v20.19 or higher. With Node.js already on your machine, run a quick version check to confirm everything is ready.

  • Open a terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Check your Node.js version by running this command:
node --version

You should see a version number starting with v20.19 or higher (for example, v20.19.0 or v22.x.x).

Seeing a version lower than v20.19?

Prisma 7 requires Node.js v20.19 or higher. Visit nodejs.org to download and install the latest LTS version, then reopen your terminal and run node --version again.

help me install Node.js v20.19 or higher on my machine

Scaffold the Next.js 16 project

Next.js provides a scaffolding tool called create-next-app that generates a full project structure with TypeScript, Tailwind CSS, and the App Router already configured.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest inventory-manager

What prompts will I see?

The create-next-app wizard will ask you one question after the project name:

"Would you like to use the recommended Next.js defaults?" Select Yes, use recommended defaults. This enables TypeScript, ESLint, Tailwind CSS, App Router, and AGENTS.md in one step.

  • When the wizard asks about recommended defaults, select Yes, use recommended defaults and press Enter.
  • Wait for the installation to finish. This may take a minute while npm downloads packages.
  • Move into your new project folder by running:
cd inventory-manager

You should now be inside the inventory-manager directory. You can confirm by running ls and seeing files like package.json, app/, and tsconfig.json.

Command not found or permission error?

Make sure you are using a terminal with Node.js in its PATH. If npx is not recognized, close and reopen your terminal after installing Node.js.

help me fix an issue running npx create-next-app

Install Prisma and Recharts dependencies

Your project needs three groups of additional packages: Prisma for database access, the SQLite adapter for local storage, and Recharts for dashboard visualizations.

  • Install Prisma's development tools by running:
npm install prisma @types/better-sqlite3 --save-dev

What does this install?

prisma is the CLI tool that manages your database schema and migrations. @types/better-sqlite3 provides TypeScript type definitions for the SQLite driver.

  • Install the runtime database packages by running:
npm install @prisma/client @prisma/adapter-better-sqlite3 dotenv

What does this install?

@prisma/client is the auto-generated query builder you will use in your app code. @prisma/adapter-better-sqlite3 connects Prisma to a local SQLite database file. dotenv loads environment variables from a .env file.

  • Install Recharts for dashboard charts by running:
npm install recharts react-is

Why react-is?

Recharts requires react-is as a peer dependency for compatibility with React 19. Installing it alongside Recharts ensures the charting library works correctly.

  • Confirm all packages installed successfully by running:
ls node_modules/.package-lock.json 2>/dev/null && echo "Packages installed" || cat package.json | head -5

Open package.json in your editor and verify that dependencies includes next, react, @prisma/client, recharts, and dotenv. Your devDependencies should include prisma and typescript.

✔️ I see all the packages in package.json

Great. Your project is fully set up with all the dependencies you need for this project.

ⓧ I see errors during install

If you see errors related to better-sqlite3 compilation, your system may be missing native build tools.

  • On macOS, run xcode-select --install in your terminal to install command-line developer tools.
  • On Windows, install the Visual Studio Build Tools from the Microsoft website.
  • On Linux, run sudo apt install build-essential to install compilation tools.
  • After installing build tools, delete the node_modules folder and run npm install again.

help me fix build errors when installing better-sqlite3

  • Open the inventory-manager folder in VS Code by pressing Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), typing Open Folder, and selecting the inventory-manager folder on your Desktop.

Your development environment is ready with Next.js, Prisma, and Recharts all installed. Next up, you will design the database schema that stores procurement and sales records.

Design the Database Schema

Your development environment is ready and all packages are installed. Now it's time to design the foundation of your app: the database.

Every record your app tracks (incoming stock, outgoing sales) needs a structured home. In this step, you'll use Prisma ORM to define your database schema, then run a migration to create the actual SQLite database file.

In this step, get ready to:
  • Initialize Prisma with the SQLite provider.
  • Define the Procurement and Sale models.
  • Run your first migration and generate the Prisma Client.
Initialize Prisma with SQLite provider

Before you can define models, Prisma needs a configuration directory and starter files. The init command scaffolds everything for you.

  • Initialize Prisma in your project by running this command:
npx prisma init --datasource-provider sqlite --output ../generated/prisma

What does this command do?

  • Creates a prisma/ folder with a schema.prisma file inside.
  • Creates a .env file at the project root with DATABASE_URL pointing to a local SQLite file.
  • The --datasource-provider sqlite flag tells Prisma to use SQLite instead of PostgreSQL.
  • The --output flag sets where the generated Prisma Client code will live.
  • Confirm the Prisma folder was created by running:
ls prisma

You should see schema.prisma in the output.

Command not found?

Make sure prisma is listed in the devDependencies section of your package.json. If it's missing, run npm install prisma --save-dev and try again.

Help me fix my Prisma init error

Prisma 7 requires a configuration file at the project root to locate your schema and database URL.

  • Create a new file called prisma.config.ts in the root of your inventory-manager folder and paste this code:
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});

What does this config do?

  • Points Prisma to your schema file location.
  • Sets the migrations output folder to prisma/migrations/.
  • Reads the database URL from your .env file using env("DATABASE_URL").
  • Save prisma.config.ts.
  • Open .env in VS Code and confirm it contains this line:
DATABASE_URL="file:./dev.db"

If your .env already has this value, you're good to move on.

✔️ Awesome, I've got everything!

Great. Make sure both prisma.config.ts and .env are saved.

ⓧ I'd like to double check the full code

Here is the full prisma.config.ts file:

import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});

And here is the full .env file:

DATABASE_URL="file:./dev.db"
Define the Procurement and Sale models

Models define the shape of your data. Each model becomes a table in the database with columns matching the fields you declare.

  • Open prisma/schema.prisma in VS Code.
  • Replace its entire contents with the following schema:
generator client {
  provider = "prisma-client"
  output   = "../app/generated/prisma"
}

datasource db {
  provider = "sqlite"
}

model Procurement {
  id             Int      @id @default(autoincrement())
  date           DateTime
  itemCode       String
  itemName       String
  colour         String
  size           String
  quantity       Int
  price          Float
  supplierName   String
  contactDetails String
  createdAt      DateTime @default(now())
}

model Sale {
  id            Int      @id @default(autoincrement())
  date          DateTime
  itemCode      String
  itemName      String
  colour        String
  size          String
  quantity      Int
  sellingPrice  Float
  customerName  String
  contactNumber String
  createdAt     DateTime @default(now())
}

What do these models define?

The Procurement model tracks incoming stock: what item came in, how much it cost per unit, and who supplied it.

The Sale model tracks outgoing orders: what item was sold, at what selling price, and to which customer.

  • Both models share common fields like itemCode, itemName, colour, size, and quantity.
  • The @id @default(autoincrement()) annotation gives each record a unique auto-incrementing ID.
  • The @default(now()) on createdAt automatically timestamps when a record is added.
  • Save prisma/schema.prisma.

Why two separate models instead of one?

Procurement and sales are fundamentally different business events. Procurement records track what you paid (cost price), while sales records track what customers paid (selling price).

Keeping them separate makes profit calculations straightforward: subtract cost from revenue per item.

✔️ Awesome, I've got everything!

Great. Make sure you've saved prisma/schema.prisma with both models.

ⓧ I'd like to double check the full code

generator client {
  provider = "prisma-client"
  output   = "../app/generated/prisma"
}

datasource db {
  provider = "sqlite"
}

model Procurement {
  id             Int      @id @default(autoincrement())
  date           DateTime
  itemCode       String
  itemName       String
  colour         String
  size           String
  quantity       Int
  price          Float
  supplierName   String
  contactDetails String
  createdAt      DateTime @default(now())
}

model Sale {
  id            Int      @id @default(autoincrement())
  date          DateTime
  itemCode      String
  itemName      String
  colour        String
  size          String
  quantity      Int
  sellingPrice  Float
  customerName  String
  contactNumber String
  createdAt     DateTime @default(now())
}
Run migrations and generate the Prisma Client

A migration translates your schema into actual database tables. The Prisma Client gives your app type-safe functions to read and write data.

  • Run your first migration by running this command:
npx prisma migrate dev --name init

What does this command do?

  • Creates the actual SQLite database file at prisma/dev.db.
  • Generates the Prisma Client code into app/generated/prisma/.
  • Creates a prisma/migrations/ folder with SQL files that track your schema changes over time.
  • The --name init flag labels this migration "init" so you can identify it later.
  • Confirm the database was created by running:
ls prisma

You should see dev.db, migrations/, and schema.prisma in the output.

Migration failed?

  • Check that your .env file contains DATABASE_URL="file:./dev.db" with no extra spaces.
  • Confirm prisma.config.ts exists in the root of your inventory-manager folder (not inside prisma/).
  • If you see a compilation error about better-sqlite3, you may need build tools installed. On Linux, run sudo apt install build-essential and try again.

Why is my Prisma migration failing?

Now that the Prisma Client is generated, you can create the helper file your app will use to access the database.

  • Create a new folder called lib in the root of your inventory-manager folder.
  • Create a new file called prisma.ts inside the lib folder and paste this code:
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../app/generated/prisma/client";

// Attach Prisma to the global object to prevent duplicate connections during hot reload
const globalForPrisma = global as unknown as { prisma: PrismaClient };

// Create the SQLite adapter pointing to the database file
const adapter = new PrismaBetterSqlite3({ url: "file:./prisma/dev.db" });

// Use the existing global client, or create a new one with the adapter
const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    adapter,
  });

// In development, store the client globally so hot reloads reuse it
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export { prisma };

What does this file do?

This file creates a singleton Prisma Client that won't spawn duplicate database connections during Next.js hot reloads in development mode.

  • The PrismaBetterSqlite3 adapter gives Prisma high-performance synchronous access to your SQLite file.
  • Storing the client on the global object means Next.js can restart your server code without opening a new connection each time.
  • Every file in your app will import prisma from this one location.
  • Save lib/prisma.ts.

Seeing a red squiggly on the import?

If VS Code shows an error on the "../app/generated/prisma/client" import, it means the generated client wasn't created yet. Run npx prisma generate in your terminal and the error should disappear.

Why does VS Code show a red error on my Prisma import?

✔️ Awesome, I've got everything!

Great. Double check that lib/prisma.ts is saved and you can see prisma/dev.db in your file tree.

ⓧ I'd like to double check the full code

Here is the full lib/prisma.ts file:

import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../app/generated/prisma/client";

const globalForPrisma = global as unknown as { prisma: PrismaClient };

const adapter = new PrismaBetterSqlite3({ url: "file:./prisma/dev.db" });

const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    adapter,
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export { prisma };

Your database is live and your schema is locked in. Next up, you'll build the procurement form that writes records into these tables.

Build the Procurement Data Entry Form

Your database is set up and ready to store records. Now you need a way for users to actually enter data into it.

In this step, you will build a procurement data entry form that connects directly to your SQLite database. The form uses Server Actions to handle submissions on the server without needing a separate API layer.

In this step, get ready to:
  • Create a procurement page with nine input fields.
  • Implement a Server Action that validates and saves records to the database.
  • Add success and error feedback after form submission.
Create the procurement page and form component

In the Next.js App Router, every folder inside app/ that contains a page.tsx file becomes a route. You will create an app/procurement/ folder to give your form its own URL at /procurement.

  • In VS Code, create a new folder called procurement inside the app/ directory.
  • Inside app/procurement/, create a new file called page.tsx and paste the following code:
import { ProcurementForm } from "./ProcurementForm";

export default function ProcurementPage() {
  return (
    <div>
      <h2 className="text-2xl font-bold text-gray-900 mb-6">
        Procurement Entry
      </h2>
      <div className="bg-white rounded-lg shadow p-6">
        <ProcurementForm />
      </div>
    </div>
  );
}

What does this code do?

This is a Server Component that renders the page layout. It imports a ProcurementForm Client Component (which you will create next) and wraps it in a styled container with a heading.

  • Save page.tsx.

You will not see a working page yet because the ProcurementForm component does not exist. You will create it next.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app/procurement/page.tsx before continuing.

ⓧ I'd like to double check the full code

import { ProcurementForm } from "./ProcurementForm";

export default function ProcurementPage() {
  return (
    <div>
      <h2 className="text-2xl font-bold text-gray-900 mb-6">
        Procurement Entry
      </h2>
      <div className="bg-white rounded-lg shadow p-6">
        <ProcurementForm />
      </div>
    </div>
  );
}

Now you need the form component itself. This is a Client Component because it uses React hooks to manage form state and pending UI.

  • Inside app/procurement/, create a new file called ProcurementForm.tsx and paste the following code:
"use client";

import { useActionState } from "react";
import { createProcurement } from "./actions";

export function ProcurementForm() {
  const [state, formAction, isPending] = useActionState(createProcurement, {
    success: false,
    message: "",
  });

  return (
    <form action={formAction} className="space-y-4">
      {state.message && (
        <div
          className={`p-3 rounded-md text-sm ${
            state.success
              ? "bg-green-50 text-green-800"
              : "bg-red-50 text-red-800"
          }`}
        >
          {state.message}
        </div>
      )}

      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Date
          </label>
          <input
            type="date"
            name="date"
            required
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Item Code
          </label>
          <input
            type="text"
            name="itemCode"
            required
            placeholder="e.g. SKU001"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Item Name
          </label>
          <input
            type="text"
            name="itemName"
            required
            placeholder="e.g. Cotton T-Shirt"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Colour
          </label>
          <input
            type="text"
            name="colour"
            required
            placeholder="e.g. Blue"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Size
          </label>
          <input
            type="text"
            name="size"
            required
            placeholder="e.g. M"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Quantity
          </label>
          <input
            type="number"
            name="quantity"
            required
            min="1"
            placeholder="e.g. 100"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Price (Cost per unit)
          </label>
          <input
            type="number"
            name="price"
            required
            min="0"
            step="0.01"
            placeholder="e.g. 12.50"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Supplier Name
          </label>
          <input
            type="text"
            name="supplierName"
            required
            placeholder="e.g. ABC Textiles"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div className="md:col-span-2">
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Contact Details
          </label>
          <input
            type="text"
            name="contactDetails"
            required
            placeholder="e.g. supplier@email.com or +1234567890"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 font-medium"
      >
        {isPending ? "Saving..." : "Add Procurement Record"}
      </button>
    </form>
  );
}

What does this code do?

The "use client" directive marks this as a Client Component so it can use React hooks in the browser.

The useActionState hook connects the form to a Server Action called createProcurement. It returns the current state (success/error messages), the form action function, and a pending boolean for showing a loading state.

The form renders nine input fields arranged in a two-column grid. Each field uses HTML validation attributes like required and min for basic client-side checks. The submit button shows "Saving..." while the action is in progress.

What is useActionState?

The useActionState hook is part of React 19. It replaces the older useFormState hook and adds a built-in isPending value so you can show loading indicators without extra state management.

When a user submits the form, React automatically wraps the submission in a transition. Your Server Action receives the previous state and the submitted FormData object.

  • Save ProcurementForm.tsx.

You will not see the form render yet because it imports createProcurement from ./actions, which does not exist. The next substep creates it.

✔️ Awesome, I've got everything!

Great. Double check you have saved ProcurementForm.tsx before moving on.

ⓧ I'd like to double check the full code

"use client";

import { useActionState } from "react";
import { createProcurement } from "./actions";

export function ProcurementForm() {
  const [state, formAction, isPending] = useActionState(createProcurement, {
    success: false,
    message: "",
  });

  return (
    <form action={formAction} className="space-y-4">
      {state.message && (
        <div
          className={`p-3 rounded-md text-sm ${
            state.success
              ? "bg-green-50 text-green-800"
              : "bg-red-50 text-red-800"
          }`}
        >
          {state.message}
        </div>
      )}

      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Date</label>
          <input type="date" name="date" required className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm" />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Item Code</label>
          <input type="text" name="itemCode" required placeholder="e.g. SKU001" className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm" />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Item Name</label>
          <input type="text" name="itemName" required placeholder="e.g. Cotton T-Shirt" className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm" />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Colour</label>
          <input type="text" name="colour" required placeholder="e.g. Blue" className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm" />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Size</label>
          <input type="text" name="size" required placeholder="e.g. M" className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm" />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Quantity</label>
          <input type="number" name="quantity" required min="1" placeholder="e.g. 100" className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm" />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Price (Cost per unit)</label>
          <input type="number" name="price" required min="0" step="0.01" placeholder="e.g. 12.50" className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm" />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Supplier Name</label>
          <input type="text" name="supplierName" required placeholder="e.g. ABC Textiles" className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm" />
        </div>
        <div className="md:col-span-2">
          <label className="block text-sm font-medium text-gray-700 mb-1">Contact Details</label>
          <input type="text" name="contactDetails" required placeholder="e.g. supplier@email.com or +1234567890" className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm" />
        </div>
      </div>

      <button type="submit" disabled={isPending} className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 font-medium">
        {isPending ? "Saving..." : "Add Procurement Record"}
      </button>
    </form>
  );
}
Implement the server action

A Server Action is a function that runs exclusively on the server. It receives form data, validates it, and writes directly to your database. No API route needed.

  • Inside app/procurement/, create a new file called actions.ts and paste the following code:
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

interface FormState {
  success: boolean;
  message: string;
}

export async function createProcurement(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  try {
    const date = formData.get("date") as string;
    const itemCode = formData.get("itemCode") as string;
    const itemName = formData.get("itemName") as string;
    const colour = formData.get("colour") as string;
    const size = formData.get("size") as string;
    const quantity = parseInt(formData.get("quantity") as string, 10);
    const price = parseFloat(formData.get("price") as string);
    const supplierName = formData.get("supplierName") as string;
    const contactDetails = formData.get("contactDetails") as string;

    if (!date || !itemCode || !itemName || !colour || !size || !supplierName || !contactDetails) {
      return { success: false, message: "All fields are required." };
    }

    if (isNaN(quantity) || quantity < 1) {
      return { success: false, message: "Quantity must be at least 1." };
    }

    if (isNaN(price) || price < 0) {
      return { success: false, message: "Price must be a valid number." };
    }

    await prisma.procurement.create({
      data: {
        date: new Date(date),
        itemCode,
        itemName,
        colour,
        size,
        quantity,
        price,
        supplierName,
        contactDetails,
      },
    });

    revalidatePath("/");
    return { success: true, message: "Procurement record added successfully!" };
  } catch (error) {
    return { success: false, message: "Failed to save record. Please try again." };
  }
}

What does this code do?

The "use server" directive at the top marks every export in this file as a Server Action. The function never runs in the browser.

The createProcurement function extracts each field from FormData, validates that all required fields are present and that numeric values are valid, then calls prisma.procurement.create() to insert a new row into the database.

After a successful insert, revalidatePath("/") tells Next.js to refresh the cached data for the home page. This ensures the dashboard (which you will build later) always shows the latest records.

  • Save actions.ts.

Seeing a compile error?

Make sure the "use server" directive is the very first line in actions.ts (no blank lines above it). Also check that your lib/prisma.ts file exists from the previous step.

If you see an import error mentioning @/lib/prisma, confirm that your tsconfig.json has the @/* path alias configured (this is set up by default with create-next-app).

Help me debug a compile error in my procurement Server Action.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before testing.

ⓧ I'd like to double check the full code

"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

interface FormState {
  success: boolean;
  message: string;
}

export async function createProcurement(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  try {
    const date = formData.get("date") as string;
    const itemCode = formData.get("itemCode") as string;
    const itemName = formData.get("itemName") as string;
    const colour = formData.get("colour") as string;
    const size = formData.get("size") as string;
    const quantity = parseInt(formData.get("quantity") as string, 10);
    const price = parseFloat(formData.get("price") as string);
    const supplierName = formData.get("supplierName") as string;
    const contactDetails = formData.get("contactDetails") as string;

    if (!date || !itemCode || !itemName || !colour || !size || !supplierName || !contactDetails) {
      return { success: false, message: "All fields are required." };
    }
    if (isNaN(quantity) || quantity < 1) {
      return { success: false, message: "Quantity must be at least 1." };
    }
    if (isNaN(price) || price < 0) {
      return { success: false, message: "Price must be a valid number." };
    }

    await prisma.procurement.create({
      data: { date: new Date(date), itemCode, itemName, colour, size, quantity, price, supplierName, contactDetails },
    });

    revalidatePath("/");
    return { success: true, message: "Procurement record added successfully!" };
  } catch (error) {
    return { success: false, message: "Failed to save record. Please try again." };
  }
}
Test the procurement form

All three files are in place. Time to verify the form works end to end.

  • Start your dev server by running:
  • Open http://localhost:3000/procurement in your browser.
npm run dev
  • You should see the "Procurement Entry" heading and a form with nine input fields arranged in a two-column grid.
  • Fill in the form with test data (for example, date: today, item code: SKU001, item name: Cotton T-Shirt, colour: Blue, size: M, quantity: 100, price: 12.50, supplier: ABC Textiles, contact: supplier@email.com).
  • Click Add Procurement Record.
  • You should see a green success message: "Procurement record added successfully!"

Why does the form show a green or red banner?

The state.message value returned by your Server Action drives the banner. A successful save returns { success: true, message: "..." } which renders with a green background. A validation failure or database error returns { success: false, message: "..." } which renders with a red background.

Not seeing the success message?

Make sure all three files (page.tsx, ProcurementForm.tsx, and actions.ts) are saved inside app/procurement/.

If you see "Failed to save record", check that your prisma/dev.db file exists. Run npx prisma migrate dev again if the database file is missing.

Help me debug why my procurement form submission is failing.

Your first data entry form is live and writing to the database. Next up, you will build the sales form so you can track outgoing orders alongside your procurement records.

Build the Sales Data Entry Form

Your procurement form is saving stock records to the database. Now you need a way to track when items leave your inventory through sales.

The sales form follows the same architecture as procurement. You will create a page component, a client-side form using useActionState, and a Server Action that validates input and writes to the database.

In this step, get ready to:
  • Create the sales page and form component with all required fields.
  • Implement a server action to validate and save sales records.
  • Test the form by submitting a sales record and verifying success feedback.
Create the sales page with a form

The sales page needs the same structure as procurement: a server component that renders a client-side form. The form captures different fields though, tracking the selling price and customer details instead of cost price and supplier details.

  • In VS Code, create a new folder called sales inside the app directory.
  • Create a new file called page.tsx inside app/sales/ and add the following code:
import { SalesForm } from "./SalesForm";

export default function SalesPage() {
  return (
    <div>
      <h2 className="text-2xl font-bold text-gray-900 mb-6">Sales Entry</h2>
      <div className="bg-white rounded-lg shadow p-6">
        <SalesForm />
      </div>
    </div>
  );
}

What does this code do?

This is a server component that renders the page heading and a card container. It imports the SalesForm client component (which you will create next) and renders it inside a styled card.

  • Save page.tsx.

Now you need the form component itself. This is the bulk of the sales page.

  • Create a new file called SalesForm.tsx inside app/sales/ and add the following code:
"use client";

import { useActionState } from "react";
import { createSale } from "./actions";

export function SalesForm() {
  const [state, formAction, isPending] = useActionState(createSale, {
    success: false,
    message: "",
  });

  return (
    <form action={formAction} className="space-y-4">
      {state.message && (
        <div
          className={`p-3 rounded-md text-sm ${
            state.success
              ? "bg-green-50 text-green-800"
              : "bg-red-50 text-red-800"
          }`}
        >
          {state.message}
        </div>
      )}

      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Date
          </label>
          <input
            type="date"
            name="date"
            required
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Item Code
          </label>
          <input
            type="text"
            name="itemCode"
            required
            placeholder="e.g. SKU001"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Item Name
          </label>
          <input
            type="text"
            name="itemName"
            required
            placeholder="e.g. Cotton T-Shirt"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Colour
          </label>
          <input
            type="text"
            name="colour"
            required
            placeholder="e.g. Blue"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Size
          </label>
          <input
            type="text"
            name="size"
            required
            placeholder="e.g. M"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Quantity
          </label>
          <input
            type="number"
            name="quantity"
            required
            min="1"
            placeholder="e.g. 5"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Selling Price (per unit)
          </label>
          <input
            type="number"
            name="sellingPrice"
            required
            min="0"
            step="0.01"
            placeholder="e.g. 25.00"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Customer Name
          </label>
          <input
            type="text"
            name="customerName"
            required
            placeholder="e.g. John Smith"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div className="md:col-span-2">
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Contact Number
          </label>
          <input
            type="tel"
            name="contactNumber"
            required
            placeholder="e.g. +1234567890"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 disabled:opacity-50 font-medium"
      >
        {isPending ? "Saving..." : "Add Sales Record"}
      </button>
    </form>
  );
}

What's different from the procurement form?

The structure mirrors ProcurementForm.tsx but with sales-specific fields. Instead of price and supplierName, this form captures sellingPrice and customerName.

The submit button is green (bg-green-600) instead of blue to visually distinguish sales from procurement. The contactNumber field uses type="tel" for phone number input on mobile devices.

  • Save SalesForm.tsx.

Seeing a red underline on the import?

VS Code may show an error on the import { createSale } from "./actions" line because that file does not exist yet. This is expected and will resolve in the next substep.

Help me fix import errors in my SalesForm component.

Implement the server action for sales

The form needs a server-side function to validate the submitted data and write it to the Sale table in your database.

  • Create a new file called actions.ts inside app/sales/ and add the following code:
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

interface FormState {
  success: boolean;
  message: string;
}

export async function createSale(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  try {
    // Extract all form field values
    const date = formData.get("date") as string;
    const itemCode = formData.get("itemCode") as string;
    const itemName = formData.get("itemName") as string;
    const colour = formData.get("colour") as string;
    const size = formData.get("size") as string;
    const quantity = parseInt(formData.get("quantity") as string, 10);
    const sellingPrice = parseFloat(formData.get("sellingPrice") as string);
    const customerName = formData.get("customerName") as string;
    const contactNumber = formData.get("contactNumber") as string;

    // Validate required text fields
    if (!date || !itemCode || !itemName || !colour || !size || !customerName || !contactNumber) {
      return { success: false, message: "All fields are required." };
    }

    // Validate numeric fields
    if (isNaN(quantity) || quantity < 1) {
      return { success: false, message: "Quantity must be at least 1." };
    }

    if (isNaN(sellingPrice) || sellingPrice < 0) {
      return { success: false, message: "Selling price must be a valid number." };
    }

    // Save the record to the database
    await prisma.sale.create({
      data: {
        date: new Date(date),
        itemCode,
        itemName,
        colour,
        size,
        quantity,
        sellingPrice,
        customerName,
        contactNumber,
      },
    });

    // Refresh the dashboard so it shows the new data
    revalidatePath("/");
    return { success: true, message: "Sales record added successfully!" };
  } catch (error) {
    return { success: false, message: "Failed to save record. Please try again." };
  }
}

What does this server action do?

  • Extracts each field from the form submission using formData.get().
  • Validates that all required text fields are present and that numeric fields are valid.
  • Calls prisma.sale.create() to insert a new row into the Sale table.
  • Calls revalidatePath("/") so the dashboard page fetches fresh data on the next visit.
  • Save actions.ts.

Seeing a type error on prisma.sale?

If prisma.sale is not recognized, your Prisma Client may be out of date. Run npx prisma generate in your terminal to regenerate it from your schema.

Help me fix Prisma type errors in my sales action.

Test the sales form

With all three files in place, the sales form is ready to accept submissions. Time to verify it works end to end.

  • Make sure your dev server is running. If it is not, start it by running:
npm run dev
  • Open your browser and navigate to http://localhost:3000/sales.
  • Fill in all the form fields with test data. For example, use today's date, item code SKU001, item name Cotton T-Shirt, quantity 5, and selling price 25.00.
  • Click Add Sales Record.

You should see a green success banner at the top of the form reading "Sales record added successfully!" and all fields should remain ready for the next entry.

Form submission not working?

  • Check that all three files are in the correct folder: app/sales/page.tsx, app/sales/SalesForm.tsx, and app/sales/actions.ts.
  • Make sure SalesForm.tsx has "use client" as the very first line and actions.ts has "use server" as the very first line.
  • Confirm that the function name createSale in your import matches the exported function name in actions.ts.

Help me debug my sales form submission.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in the sales folder.

ⓧ I'd like to double check the full code

Here is the complete code for each file in your sales folder.

import { SalesForm } from "./SalesForm";

export default function SalesPage() {
  return (
    <div>
      <h2 className="text-2xl font-bold text-gray-900 mb-6">Sales Entry</h2>
      <div className="bg-white rounded-lg shadow p-6">
        <SalesForm />
      </div>
    </div>
  );
}
"use client";

import { useActionState } from "react";
import { createSale } from "./actions";

export function SalesForm() {
  const [state, formAction, isPending] = useActionState(createSale, {
    success: false,
    message: "",
  });

  return (
    <form action={formAction} className="space-y-4">
      {state.message && (
        <div
          className={`p-3 rounded-md text-sm ${
            state.success
              ? "bg-green-50 text-green-800"
              : "bg-red-50 text-red-800"
          }`}
        >
          {state.message}
        </div>
      )}

      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Date
          </label>
          <input
            type="date"
            name="date"
            required
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Item Code
          </label>
          <input
            type="text"
            name="itemCode"
            required
            placeholder="e.g. SKU001"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Item Name
          </label>
          <input
            type="text"
            name="itemName"
            required
            placeholder="e.g. Cotton T-Shirt"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Colour
          </label>
          <input
            type="text"
            name="colour"
            required
            placeholder="e.g. Blue"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Size
          </label>
          <input
            type="text"
            name="size"
            required
            placeholder="e.g. M"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Quantity
          </label>
          <input
            type="number"
            name="quantity"
            required
            min="1"
            placeholder="e.g. 5"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Selling Price (per unit)
          </label>
          <input
            type="number"
            name="sellingPrice"
            required
            min="0"
            step="0.01"
            placeholder="e.g. 25.00"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Customer Name
          </label>
          <input
            type="text"
            name="customerName"
            required
            placeholder="e.g. John Smith"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div className="md:col-span-2">
          <label className="block text-sm font-medium text-gray-700 mb-1">
            Contact Number
          </label>
          <input
            type="tel"
            name="contactNumber"
            required
            placeholder="e.g. +1234567890"
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 disabled:opacity-50 font-medium"
      >
        {isPending ? "Saving..." : "Add Sales Record"}
      </button>
    </form>
  );
}
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

interface FormState {
  success: boolean;
  message: string;
}

export async function createSale(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  try {
    const date = formData.get("date") as string;
    const itemCode = formData.get("itemCode") as string;
    const itemName = formData.get("itemName") as string;
    const colour = formData.get("colour") as string;
    const size = formData.get("size") as string;
    const quantity = parseInt(formData.get("quantity") as string, 10);
    const sellingPrice = parseFloat(formData.get("sellingPrice") as string);
    const customerName = formData.get("customerName") as string;
    const contactNumber = formData.get("contactNumber") as string;

    if (!date || !itemCode || !itemName || !colour || !size || !customerName || !contactNumber) {
      return { success: false, message: "All fields are required." };
    }

    if (isNaN(quantity) || quantity < 1) {
      return { success: false, message: "Quantity must be at least 1." };
    }

    if (isNaN(sellingPrice) || sellingPrice < 0) {
      return { success: false, message: "Selling price must be a valid number." };
    }

    await prisma.sale.create({
      data: {
        date: new Date(date),
        itemCode,
        itemName,
        colour,
        size,
        quantity,
        sellingPrice,
        customerName,
        contactNumber,
      },
    });

    revalidatePath("/");
    return { success: true, message: "Sales record added successfully!" };
  } catch (error) {
    return { success: false, message: "Failed to save record. Please try again." };
  }
}

Both of your data entry forms are now live and writing to the database. Next up, you will build the analytics dashboard that reads this data and computes business metrics like revenue and profit.

Build the Analytics Dashboard

Your Procurement and Sales forms are collecting data into the database. The next question is: what does all that data actually tell you about the business?

In this step, you'll build an analytics dashboard that queries every procurement and sales record, computes key business metrics, and displays them alongside a bar chart of current stock levels.

In this step, get ready to:
  • Install Recharts and create a bar chart component.
  • Query the database to compute total sales, revenue, profit, and stock levels.
  • Render metric cards, a stock chart, and a product availability table.
Install Recharts and create the chart component

The dashboard needs a visual chart to show stock levels at a glance. Recharts is a React-native charting library that uses declarative components to render SVG charts.

  • Install Recharts and its peer dependency by running this command in your terminal:
npm install recharts react-is
  • Confirm the install succeeded. You should see recharts and react-is added to your package.json dependencies.

Install failed?

Make sure your terminal is inside the inventory-manager project folder. Run ls and check you can see package.json in the output.

help me fix my Recharts install error

Now you'll create a client component that wraps the Recharts bar chart. This component receives stock data as a prop and renders it visually.

  • Create a new folder called components inside the app directory.
  • Inside app/components, create a file called DashboardCharts.tsx and paste the following code:
"use client";

import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from "recharts";

// Define the shape of each stock item
interface StockItem {
  itemCode: string;
  itemName: string;
  stock: number;
}

export function DashboardCharts({ stockData }: { stockData: StockItem[] }) {
  // Show a placeholder message if there's no data yet
  if (stockData.length === 0) {
    return <p className="text-gray-500 text-center py-8">No data to display yet.</p>;
  }

  return (
    <ResponsiveContainer width="100%" height={300}>
      <BarChart data={stockData}>
        <CartesianGrid strokeDasharray="3 3" />
        <XAxis dataKey="itemName" />
        <YAxis />
        <Tooltip />
        <Bar dataKey="stock" fill="#3b82f6" />
      </BarChart>
    </ResponsiveContainer>
  );
}

What does this code do?

  • "use client" marks this as a client component because Recharts uses browser APIs for rendering interactive SVG.
  • ResponsiveContainer makes the chart resize to fill its parent container.
  • BarChart renders one bar per item, with dataKey="stock" pulling the stock count from each object in the stockData array.
  • The empty-state check returns a message instead of an empty chart when no procurement records exist yet.
  • Save app/components/DashboardCharts.tsx.

You won't see a change yet. The next chunk builds the dashboard page that uses this component.

Query the database to compute metrics

The dashboard page needs to fetch all procurement and sales records, then compute totals from them. You'll write an async server function that calculates total items sold, total revenue, total profit, and current stock levels per item.

  • Open app/page.tsx in VS Code. This is the default home page of your Next.js app. Replace its entire contents with the following code:
import { prisma } from "@/lib/prisma";
import { DashboardCharts } from "./components/DashboardCharts";

// Fetch all records and compute business metrics
async function getDashboardData() {
  const sales = await prisma.sale.findMany();
  const procurements = await prisma.procurement.findMany();

  // Total quantity of all items sold
  const totalItemsSold = sales.reduce((sum, sale) => sum + sale.quantity, 0);

  // Total revenue from all sales
  const totalRevenue = sales.reduce((sum, sale) => sum + sale.quantity * sale.sellingPrice, 0);

  // Build a map of total cost per item code
  const costMap = new Map<string, number>();
  for (const p of procurements) {
    const existing = costMap.get(p.itemCode) || 0;
    costMap.set(p.itemCode, existing + p.quantity * p.price);
  }

  // Build a map of total revenue per item code
  const revenueMap = new Map<string, number>();
  for (const s of sales) {
    const existing = revenueMap.get(s.itemCode) || 0;
    revenueMap.set(s.itemCode, existing + s.quantity * s.sellingPrice);
  }

  // Calculate profit: selling price minus cost price per unit sold
  let totalProfit = 0;
  for (const s of sales) {
    const procurementForItem = procurements.find((p) => p.itemCode === s.itemCode);
    const costPrice = procurementForItem ? procurementForItem.price : 0;
    totalProfit += s.quantity * (s.sellingPrice - costPrice);
  }

  // Build stock levels: procured quantity minus sold quantity per item
  const stockMap = new Map<string, { name: string; stock: number }>();
  for (const p of procurements) {
    const existing = stockMap.get(p.itemCode) || { name: p.itemName, stock: 0 };
    existing.stock += p.quantity;
    stockMap.set(p.itemCode, existing);
  }
  for (const s of sales) {
    const existing = stockMap.get(s.itemCode) || { name: s.itemName, stock: 0 };
    existing.stock -= s.quantity;
    stockMap.set(s.itemCode, existing);
  }

  // Convert the stock map to an array for rendering
  const stockData = Array.from(stockMap.entries()).map(([itemCode, { name, stock }]) => ({ itemCode, itemName: name, stock }));

  return { totalItemsSold, totalRevenue, totalProfit, stockData };
}

What does this code do?

  • prisma.sale.findMany() and prisma.procurement.findMany() fetch every record from the database.
  • totalItemsSold sums the quantity field across all sales records.
  • totalProfit finds the procurement cost for each sold item and subtracts it from the selling price, multiplied by quantity.
  • stockMap tracks current inventory by adding procured quantities and subtracting sold quantities per item code.

Why Maps instead of a single loop?

Each metric needs a different aggregation strategy. Revenue sums price times quantity. Profit needs to look up the cost per unit from a different table. Stock needs to combine additions and subtractions per item code.

Using separate Maps keeps each calculation isolated and readable. In a production app, you could push these into SQL aggregation queries for better performance at scale.

  • Save app/page.tsx. The file is not complete yet, so the app will show an error if you try to load it.
Render metric cards, chart, and stock table

With the data function ready, you need a React component to render it. This component will display three metric cards, the Recharts bar chart, and a product availability table.

  • In app/page.tsx, add the following code below the getDashboardData function:
export default async function DashboardPage() {
  // Fetch computed metrics from the database
  const { totalItemsSold, totalRevenue, totalProfit, stockData } =
    await getDashboardData();

  return (
    <div>
      <h2 className="text-2xl font-bold text-gray-900 mb-6">
        Analytics Dashboard
      </h2>

      {/* Metric cards row */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
        <div className="bg-white rounded-lg shadow p-6">
          <p className="text-sm font-medium text-gray-500">Total Items Sold</p>
          <p className="text-3xl font-bold text-gray-900">{totalItemsSold}</p>
        </div>
        <div className="bg-white rounded-lg shadow p-6">
          <p className="text-sm font-medium text-gray-500">Total Revenue</p>
          <p className="text-3xl font-bold text-green-600">
            ${totalRevenue.toFixed(2)}
          </p>
        </div>
        <div className="bg-white rounded-lg shadow p-6">
          <p className="text-sm font-medium text-gray-500">Total Profit</p>
          <p className="text-3xl font-bold text-blue-600">
            ${totalProfit.toFixed(2)}
          </p>
        </div>
      </div>

      {/* Bar chart section */}
      <div className="bg-white rounded-lg shadow p-6 mb-8">
        <h3 className="text-lg font-semibold text-gray-900 mb-4">
          Stock Levels by Item
        </h3>
        <DashboardCharts stockData={stockData} />
      </div>

      {/* Product availability table */}
      <div className="bg-white rounded-lg shadow p-6">
        <h3 className="text-lg font-semibold text-gray-900 mb-4">
          Product Availability
        </h3>
        <table className="min-w-full divide-y divide-gray-200">
          <thead>
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Item Code
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Item Name
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Stock
              </th>
            </tr>
          </thead>
          <tbody className="divide-y divide-gray-200">
            {stockData.map((item) => (
              <tr key={item.itemCode}>
                <td className="px-6 py-4 text-sm text-gray-900">
                  {item.itemCode}
                </td>
                <td className="px-6 py-4 text-sm text-gray-900">
                  {item.itemName}
                </td>
                <td className="px-6 py-4 text-sm text-gray-900">
                  {item.stock}
                </td>
              </tr>
            ))}
            {stockData.length === 0 && (
              <tr>
                <td
                  colSpan={3}
                  className="px-6 py-4 text-sm text-gray-500 text-center"
                >
                  No inventory data yet. Add procurement records to get started.
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}

What does this code do?

  • DashboardPage is an async Server Component. It calls getDashboardData() on the server before sending HTML to the browser.
  • The three metric cards use a responsive grid that stacks on mobile and shows side-by-side on wider screens.
  • DashboardCharts receives the computed stockData array and renders the interactive bar chart on the client.
  • The table at the bottom shows the same stock data in text form, with an empty-state message when no records exist.
  • Save app/page.tsx.
  • In your terminal, start the dev server if it is not already running:
npm run dev
  • Open http://localhost:3000 in your browser. You should see the Analytics Dashboard heading, three metric cards showing 0 values, a "No data to display yet" chart placeholder, and an empty Product Availability table.

Seeing an error instead of the dashboard?

  • If you see a module-not-found error for recharts, make sure you saved the file and that the install command completed without errors.
  • If the error mentions DashboardCharts, check that the file is at app/components/DashboardCharts.tsx (not app/component/ without the s).
  • If you see a Prisma error, confirm your database migration ran successfully in Step 2. Try running npx prisma generate again.

help me debug my analytics dashboard error

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is the complete app/components/DashboardCharts.tsx:

"use client";

import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";

interface StockItem {
  itemCode: string;
  itemName: string;
  stock: number;
}

export function DashboardCharts({ stockData }: { stockData: StockItem[] }) {
  if (stockData.length === 0) {
    return <p className="text-gray-500 text-center py-8">No data to display yet.</p>;
  }
  return (
    <ResponsiveContainer width="100%" height={300}>
      <BarChart data={stockData}>
        <CartesianGrid strokeDasharray="3 3" />
        <XAxis dataKey="itemName" />
        <YAxis />
        <Tooltip />
        <Bar dataKey="stock" fill="#3b82f6" />
      </BarChart>
    </ResponsiveContainer>
  );
}

Here is the complete app/page.tsx:

import { prisma } from "@/lib/prisma";
import { DashboardCharts } from "./components/DashboardCharts";

async function getDashboardData() {
  const sales = await prisma.sale.findMany();
  const procurements = await prisma.procurement.findMany();

  const totalItemsSold = sales.reduce((sum, sale) => sum + sale.quantity, 0);
  const totalRevenue = sales.reduce((sum, sale) => sum + sale.quantity * sale.sellingPrice, 0);

  const costMap = new Map<string, number>();
  for (const p of procurements) {
    const existing = costMap.get(p.itemCode) || 0;
    costMap.set(p.itemCode, existing + p.quantity * p.price);
  }

  const revenueMap = new Map<string, number>();
  for (const s of sales) {
    const existing = revenueMap.get(s.itemCode) || 0;
    revenueMap.set(s.itemCode, existing + s.quantity * s.sellingPrice);
  }

  let totalProfit = 0;
  for (const s of sales) {
    const procurementForItem = procurements.find((p) => p.itemCode === s.itemCode);
    const costPrice = procurementForItem ? procurementForItem.price : 0;
    totalProfit += s.quantity * (s.sellingPrice - costPrice);
  }

  const stockMap = new Map<string, { name: string; stock: number }>();
  for (const p of procurements) {
    const existing = stockMap.get(p.itemCode) || { name: p.itemName, stock: 0 };
    existing.stock += p.quantity;
    stockMap.set(p.itemCode, existing);
  }
  for (const s of sales) {
    const existing = stockMap.get(s.itemCode) || { name: s.itemName, stock: 0 };
    existing.stock -= s.quantity;
    stockMap.set(s.itemCode, existing);
  }

  const stockData = Array.from(stockMap.entries()).map(([itemCode, { name, stock }]) => ({ itemCode, itemName: name, stock }));

  return { totalItemsSold, totalRevenue, totalProfit, stockData };
}

export default async function DashboardPage() {
  const { totalItemsSold, totalRevenue, totalProfit, stockData } =
    await getDashboardData();

  return (
    <div>
      <h2 className="text-2xl font-bold text-gray-900 mb-6">
        Analytics Dashboard
      </h2>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
        <div className="bg-white rounded-lg shadow p-6">
          <p className="text-sm font-medium text-gray-500">Total Items Sold</p>
          <p className="text-3xl font-bold text-gray-900">{totalItemsSold}</p>
        </div>
        <div className="bg-white rounded-lg shadow p-6">
          <p className="text-sm font-medium text-gray-500">Total Revenue</p>
          <p className="text-3xl font-bold text-green-600">
            ${totalRevenue.toFixed(2)}
          </p>
        </div>
        <div className="bg-white rounded-lg shadow p-6">
          <p className="text-sm font-medium text-gray-500">Total Profit</p>
          <p className="text-3xl font-bold text-blue-600">
            ${totalProfit.toFixed(2)}
          </p>
        </div>
      </div>

      <div className="bg-white rounded-lg shadow p-6 mb-8">
        <h3 className="text-lg font-semibold text-gray-900 mb-4">
          Stock Levels by Item
        </h3>
        <DashboardCharts stockData={stockData} />
      </div>

      <div className="bg-white rounded-lg shadow p-6">
        <h3 className="text-lg font-semibold text-gray-900 mb-4">
          Product Availability
        </h3>
        <table className="min-w-full divide-y divide-gray-200">
          <thead>
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Item Code
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Item Name
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Stock
              </th>
            </tr>
          </thead>
          <tbody className="divide-y divide-gray-200">
            {stockData.map((item) => (
              <tr key={item.itemCode}>
                <td className="px-6 py-4 text-sm text-gray-900">
                  {item.itemCode}
                </td>
                <td className="px-6 py-4 text-sm text-gray-900">
                  {item.itemName}
                </td>
                <td className="px-6 py-4 text-sm text-gray-900">
                  {item.stock}
                </td>
              </tr>
            ))}
            {stockData.length === 0 && (
              <tr>
                <td
                  colSpan={3}
                  className="px-6 py-4 text-sm text-gray-500 text-center"
                >
                  No inventory data yet. Add procurement records to get started.
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}

Your analytics dashboard is live. Next up, you'll connect all three pages with a shared navigation bar and polish the app with consistent styling.

Add Navigation and Polish

Your forms write to the database and your dashboard reads from it. The pieces work individually, but there's no way to navigate between them without manually typing URLs.

In this step, you'll tie everything together with a shared navigation bar and run a full end-to-end test of the application.

In this step, get ready to:
  • Create a shared layout with navigation links to all three pages.
  • Confirm Tailwind CSS styling is applied globally.
  • Test the full flow from procurement to sales to dashboard analytics.
Create a shared navigation layout

In Next.js, the app/layout.tsx file wraps every page with shared UI. Any component you place in the layout (like a nav bar) appears on all routes automatically.

  • In VS Code, open app/layout.tsx.
  • Replace the entire contents of the file with the following code:
import type { Metadata } from "next";
import "./globals.css";
import Link from "next/link";

// Metadata for the browser tab title and description
export const metadata: Metadata = {
  title: "Inventory & Sales Manager",
  description: "Track procurement, sales, and analytics for your business",
};

// RootLayout wraps every page with shared navigation
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="min-h-screen bg-gray-50">
        <nav className="bg-white shadow-sm border-b">
          <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
            <div className="flex justify-between h-16 items-center">
              <h1 className="text-xl font-bold text-gray-900">Inventory Manager</h1>
              <div className="flex space-x-4">
                <Link href="/" className="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100">Dashboard</Link>
                <Link href="/procurement" className="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100">Procurement</Link>
                <Link href="/sales" className="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100">Sales</Link>
              </div>
            </div>
          </div>
        </nav>
        <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">{children}</main>
      </body>
    </html>
  );
}

What does this code do?

  • The metadata export sets the browser tab title and meta description for the entire app.
  • The <nav> element creates a white top bar with three Link components pointing to each page route.
  • The {children} prop renders the current page content inside the <main> wrapper, so every page shares the same nav and layout structure.
  • Next.js Link components perform client-side navigation without full page reloads, keeping the app fast.
  • Save app/layout.tsx.

Seeing a compile error?

  • Check that the import path for globals.css is ./globals.css (relative from the same app/ folder).
  • Make sure next/link is imported with a lowercase l in link but used as <Link> with a capital L in JSX.

Why isn't my layout rendering correctly in Next.js?

Confirm Tailwind CSS is applied globally

The layout imports ./globals.css, which contains the Tailwind CSS directives. This is what makes all those utility classes (like bg-gray-50 and rounded-md) work across your entire app.

  • Open app/globals.css and confirm it contains these three lines:
@tailwind base;
@tailwind components;
@tailwind utilities;

What do these directives do?

These three lines inject Tailwind's reset styles, component classes, and utility classes into your app. Without them, classes like bg-white and shadow-sm would have no effect.

Test the full application flow

Now it's time to run the app and verify that all three pages work together. You'll add a procurement record, then a sales record, and confirm the dashboard updates with live metrics.

  • Start the development server by running this command in your terminal:
npm run dev
  • Open http://localhost:3000 in your browser.

You should see the Analytics Dashboard page with a white navigation bar across the top containing three links: Dashboard, Procurement, and Sales.

  • Click Procurement in the navigation bar.
  • Fill in the form with test data. For example, use today's date, item code SKU001, item name Cotton T-Shirt, colour Blue, size M, quantity 100, price 12.50, supplier name ABC Textiles, and any contact details.
  • Click Add Procurement Record.

You should see a green success message saying "Procurement record added successfully!" at the top of the form.

  • Click Sales in the navigation bar.
  • Fill in a sales record using the same item code SKU001, item name Cotton T-Shirt, a quantity of 5, and a selling price of 25.00. Fill in the remaining fields with any test values.
  • Click Add Sales Record.

You should see a green success message saying "Sales record added successfully!".

  • Click Dashboard in the navigation bar.

Your dashboard should now display updated metrics: Total Items Sold shows 5, Total Revenue shows $125.00, and Total Profit shows $62.50. The bar chart and Product Availability table should show Cotton T-Shirt with a stock of 95.

Where do these numbers come from?

Total Revenue is quantity sold (5) multiplied by selling price ($25.00) = $125.00. Total Profit is quantity sold (5) multiplied by the margin ($25.00 - $12.50 cost) = $62.50.

Stock is procured quantity (100) minus sold quantity (5) = 95 remaining units. These calculations run on the server every time the dashboard page loads.

Dashboard showing zeros or no data?

  • Make sure you used the same item code (SKU001) in both the procurement and sales forms. The profit calculation matches records by itemCode.
  • Check your terminal for errors. If you see a database-related error, try stopping the server and running npx prisma migrate dev again, then restart with npm run dev.

My dashboard isn't showing the correct metrics after adding records. Help me debug.

✔️ Awesome, I've got everything!

Great. Double check you've saved app/layout.tsx and confirmed app/globals.css has the Tailwind directives.

ⓧ I'd like to double check the full code

Here is the complete app/layout.tsx file:

import type { Metadata } from "next";
import "./globals.css";
import Link from "next/link";

export const metadata: Metadata = {
  title: "Inventory & Sales Manager",
  description: "Track procurement, sales, and analytics for your business",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="min-h-screen bg-gray-50">
        <nav className="bg-white shadow-sm border-b">
          <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
            <div className="flex justify-between h-16 items-center">
              <h1 className="text-xl font-bold text-gray-900">Inventory Manager</h1>
              <div className="flex space-x-4">
                <Link href="/" className="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100">Dashboard</Link>
                <Link href="/procurement" className="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100">Procurement</Link>
                <Link href="/sales" className="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100">Sales</Link>
              </div>
            </div>
          </div>
        </nav>
        <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">{children}</main>
      </body>
    </html>
  );
}

And the complete app/globals.css file:

@tailwind base;
@tailwind components;
@tailwind utilities;

Your inventory management app is now fully functional with navigation, styled forms, and a live analytics dashboard. Next up, you'll tackle a bonus challenge to make the dashboard even smarter.

Secret mission

Low-Stock Alert System

Your dashboard shows stock levels, but it doesn't shout when something is running dangerously low. In this mission, you'll add a visual alert system that flags items with fewer than 5 units remaining. The dashboard becomes proactive, warning you before stockouts happen.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally with a SQLite database file, so there are no ongoing cloud costs.

Resources you used:

  • Your Next.js project folder on the Desktop (contains all source code, node_modules, and the SQLite database).
  • The local dev server process running at http://localhost:3000.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with the app.

  • Your dev server stays running at http://localhost:3000.
  • Your SQLite database (prisma/dev.db) keeps all your procurement and sales records.
  • You can continue adding records and viewing the dashboard at any time.

Pause - I'll come back to this later

Stop the running dev server to free up your terminal, but keep your project files intact.

  • Switch to the terminal where your dev server is running.
  • Press Ctrl+C to stop the server.
  • Your project folder and database remain on your Desktop. When you want to resume, open a terminal in the project folder and run:
npm run dev

Your data will still be there when you come back.

Delete - I don't want to use this again

Remove all project files, the database, and installed packages from your machine.

  • Switch to the terminal where your dev server is running.
  • Press Ctrl+C to stop the server.
  • Navigate to your Desktop by running:
cd ~/Desktop
  • Delete the project folder (replace the folder name with whatever you named your project during create-next-app):

macOS

rm -rf inventory-sales-app

Windows

rmdir /s /q inventory-sales-app
  • Confirm the folder is gone by running:
ls

You should no longer see your project folder in the output.

Nice Work!

Nice Work!

You just built a complete business management application from scratch. Your app tracks procurement, records sales, computes real-time profit, and visualizes stock levels in an interactive dashboard.

You've learned how to:

  • Designed a relational database schema with Prisma ORM and SQLite, then connected it to a Next.js application using driver adapters.
  • Built two data entry forms powered by Server Actions for secure server-side data mutations without a separate API layer.
  • Computed business metrics (revenue, profit, stock levels) from raw database records and visualized them with interactive Recharts bar charts.
  • Implemented a low-stock alert system with conditional styling that highlights items running low in red across the dashboard.

Ready to quiz yourself?