AI Sales Manager Command Center

Build a full-stack AI SaaS dashboard that qualifies sales leads.

Introduction

30 Second Summary

Every business gets leads. People fill out forms, send emails, or reach out on social media. But qualifying those leads takes hours of repetitive back-and-forth conversations that most small business owners simply don't have time for.

In this project, you will build an AI Sales Manager command center that qualifies leads through intelligent conversation, scores them automatically, and lets you approve or reject every decision the AI makes.

What You'll Build

When you open your finished app, you see a dark, premium dashboard with lead cards flowing across a Kanban board while an AI assistant qualifies new prospects in a real-time side panel chat.

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

  • A live pipeline board where you open the app and watch leads flow from "New" through "AI Qualifying" to "Approved" or "Rejected" in real time, with cards updating instantly as the AI works.
  • A working AI chat panel where you click any lead card and watch Groq's Llama 3.3 model ask smart qualification questions about budget, timeline, and decision-making authority.
  • An owner control panel where you approve, reject, or override the AI's score on any lead with a single click, keeping you in charge of every decision.
  • Secret Mission: an AI performance analytics panel that tracks qualification accuracy and conversion rates over time.

Are there any prerequisites?

This project assumes intermediate JavaScript/TypeScript knowledge and basic React experience. You should be comfortable with terminal commands and have used components and hooks before.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Scaffold the Project and Connect Services

To build a premium AI-powered dashboard, you need three things working together: a fast frontend framework, a real-time database, and an AI API.

In this step, you will scaffold a React project with a polished component library, create free accounts on Groq and Supabase, and connect everything so your app can talk to both services.

In this step, get ready to:
  • Scaffold a Vite + React + TypeScript project with shadcn/ui and Tailwind CSS.
  • Create free Groq and Supabase accounts and store API keys in a local environment file.
  • Set up the database schema and verify the connection from your app.
Scaffold the Vite project with shadcn/ui

Your frontend stack is Vite (a fast build tool), React with TypeScript, and shadcn/ui for premium-looking components styled with Tailwind CSS.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project by running this command:
npm create vite@latest ai-sales-manager -- --template react-ts

What does this command do?

This tells Vite to scaffold a new project called ai-sales-manager using the React + TypeScript template. The -- separates npm arguments from Vite arguments.

  • Move into the new project folder and install initial dependencies by running:
cd ai-sales-manager
npm install
  • Install Tailwind CSS and its Vite plugin by running:
npm add tailwindcss @tailwindcss/vite
  • Open the project in VS Code by running:
code .

VS Code opens with your project files in the sidebar.

  • In VS Code, open src/index.css from the Explorer sidebar on the left.
  • Replace everything in the file with this single line:
@import "tailwindcss";
  • Save src/index.css.
  • Open tsconfig.json (in the project root) and replace its entire contents with:
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ],
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

What is the @ path alias?

The @/* path alias lets you import from src/ using @/ instead of long relative paths like ../../. shadcn/ui components rely on this alias to work correctly.

  • Open tsconfig.app.json and add baseUrl and paths inside the existing compilerOptions object:
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
  • Save both tsconfig files.
  • Install @types/node so Vite can resolve the path alias:
npm add -D @types/node
  • Open vite.config.ts and replace its contents with:
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"

export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
})

What does this config do?

This registers the Tailwind CSS Vite plugin and maps the @ alias to your src folder. Now both Vite's bundler and TypeScript understand your import paths.

  • Save vite.config.ts.
  • Initialize shadcn/ui by running:
npx shadcn@latest init

What will shadcn init ask you?

The CLI will prompt you to select a base library and a preset. Choose Radix as the base (the default) and pick any preset you like (e.g. Nova). It will then write a components.json config, set up CSS variables, and configure dark mode.

  • Add the UI components you will use throughout the project:
npx shadcn@latest add button card badge input label dialog

You should see each component being added to src/components/ui/ in the terminal output.

  • Verify everything works by starting the dev server:
npm run dev

Before you check the browser, what do you expect to see at localhost:5173?

Open http://localhost:5173 in your browser. You should see the default Vite + React starter page rendering on screen.

Not seeing the page?

  • Make sure you ran npm run dev from inside the ai-sales-manager folder.
  • Check that port 5173 is not already in use by another process.
  • If you see TypeScript errors, confirm you saved both tsconfig.json and vite.config.ts.

help me debug why my Vite dev server is not starting

Create Groq and Supabase accounts and store API keys

Your app needs two external services: Groq for AI chat completions and Supabase for database storage with real-time subscriptions. Both offer free tiers that fully cover this project.

  • Stop the dev server by pressing Ctrl+C in the terminal.
  • Open console.groq.com in your browser.
  • Sign up for a free account. No credit card is required.
  • After signing in, click API Keys in the left sidebar.
  • Click Create API Key.
  • Give your key a name (e.g. ai-sales-manager) and click Submit.
  • Copy the key and save it somewhere safe. You will only see it once.

What is Groq?

Groq is an AI inference platform that runs large language models extremely fast. You will use it to power your AI qualification chat with the llama-3.3-70b-versatile model.

The free tier gives you 1,000 requests per day, which is plenty for development and testing.

  • Open supabase.com in a new browser tab.
  • Sign up for a free account (you can use GitHub, email, or SSO).
  • Click New Project.
  • Enter a project name (e.g. ai-sales-manager).
  • Set a database password and save it somewhere safe.
  • Choose your preferred region and click Create new project.
  • Wait for the project to finish provisioning (about 1 minute).
  • Once provisioned, go to Settings in the left sidebar, then click API.
  • Copy the Project URL value.
  • Copy the anon public key (under Project API keys).

Now store these credentials in a local environment file that Vite reads at startup.

  • In VS Code, create a new file called .env in the root of your ai-sales-manager folder (same level as package.json).
  • Add the following content, replacing the placeholder values with your actual keys:
VITE_SUPABASE_URL=your-supabase-project-url
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
VITE_GROQ_API_KEY=your-groq-api-key

Why the VITE_ prefix?

Vite only exposes environment variables that start with VITE_ to your browser code. This prefix is required for the app to read these values at runtime.

  • Save .env.
  • Install the Groq SDK by running this in your VS Code terminal:
npm install --save groq-sdk
  • Install the Supabase client library by running:
npm install @supabase/supabase-js

You should see both packages added to your package.json dependencies.

  • Create a new folder called lib inside src/.
  • Create a new file src/lib/supabase.ts and add the following code:
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);

What does this code do?

This creates a Supabase client instance using the URL and anon key from your .env file. Every part of your app that needs the database will import from this single file.

  • Create a new file src/lib/groq.ts and add the following code:
import Groq from "groq-sdk";

const groq = new Groq({
  apiKey: import.meta.env.VITE_GROQ_API_KEY,
  dangerouslyAllowBrowser: true,
});

export { groq };

What does dangerouslyAllowBrowser mean?

The Groq SDK is designed for server-side use. The dangerouslyAllowBrowser: true flag tells the SDK it is okay to run in the browser. This is fine for development and demos.

In a production app, you would proxy API calls through a server-side function to keep your API key hidden from users.

  • Save both files.
  • Confirm the files exist by checking the Explorer sidebar in VS Code. You should see src/lib/supabase.ts and src/lib/groq.ts listed.

Seeing import errors in VS Code?

  • Make sure you ran npm install --save groq-sdk and npm install @supabase/supabase-js from inside the ai-sales-manager folder.
  • Check that your .env file is in the project root (same level as package.json), not inside src/.

help me fix import errors for groq-sdk or supabase

Set up the Supabase database schema and verify the connection

Your AI Sales Manager needs two database tables: one for leads flowing through the pipeline, and one for storing AI conversation histories.

  • In the Supabase dashboard, click SQL Editor in the left sidebar.
  • Paste the following SQL to create the leads table, then click Run:
create table leads (
  id uuid default gen_random_uuid() primary key,
  name text not null,
  company text not null,
  email text not null,
  status text default 'new',
  ai_score integer,
  ai_assessment text,
  override_score integer,
  override_note text,
  created_at timestamptz default now()
);

What do these columns store?

  • status tracks where the lead is in your pipeline (new, qualifying, qualified, approved, rejected).
  • ai_score and ai_assessment hold the AI's qualification verdict.
  • override_score and override_note let the owner override the AI's decision later.

You should see a success message after running the query.

  • Clear the SQL Editor and paste the following to create the conversations table, then click Run:
create table conversations (
  id uuid default gen_random_uuid() primary key,
  lead_id uuid not null references leads(id),
  messages jsonb default '[]'::jsonb,
  created_at timestamptz default now()
);

You should see another success message confirming the table was created.

  • Click Table Editor in the left sidebar to confirm both tables appear: leads and conversations.

Now verify the connection from your app by adding a quick status check to App.tsx.

  • In VS Code, open src/App.tsx and replace its entire contents with:
import { useEffect, useState } from "react"
import { supabase } from "@/lib/supabase"
import { Badge } from "@/components/ui/badge"

function App() {
  const [connected, setConnected] = useState(false)

  useEffect(() => {
    async function checkConnection() {
      const { error } = await supabase
        .from("leads")
        .select("id")
        .limit(1)
      if (!error) setConnected(true)
    }
    checkConnection()
  }, [])

  return (
    <div className="h-screen flex items-center justify-center bg-background text-foreground">
      <Badge variant={connected ? "default" : "secondary"}>
        {connected ? "Connected" : "Checking..."}
      </Badge>
    </div>
  )
}

export default App

What does this code do?

When the app loads, it tries to query the leads table. If the query returns without error, the Supabase connection is working and the Badge shows "Connected".

  • Save src/App.tsx.
  • Start the dev server again by running:
npm run dev

Before you check the browser, what do you think the badge will say?

Open http://localhost:5173 in your browser. You should see a dark page with a small badge that says Connected.

Badge stuck on Checking?

  • Open the browser DevTools console (F12) and look for network errors.
  • Confirm the VITE_SUPABASE_URL value in your .env looks like https://xxxxx.supabase.co.
  • If you changed the .env file while the server was running, stop it with Ctrl+C and restart with npm run dev. Vite only reads .env at startup.

help me debug why my Supabase connection badge says Checking

✔️ Awesome, I've got everything!

Great. Double check you have saved all files before moving on.

ⓧ I'd like to double check the full code

Here are the key files you created in this step. Confirm each one matches yours.

VITE_SUPABASE_URL=your-supabase-project-url
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
VITE_GROQ_API_KEY=your-groq-api-key
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);
import Groq from "groq-sdk";

const groq = new Groq({
  apiKey: import.meta.env.VITE_GROQ_API_KEY,
  dangerouslyAllowBrowser: true,
});

export { groq };

Your development environment is locked and loaded. You have a running React app with shadcn/ui components, Supabase connected and confirmed, and Groq ready to power AI conversations. Next up, you will build the dashboard shell and lead pipeline that brings this app to life.

Build the Dashboard Shell with Lead Pipeline

Your project is scaffolded, your backend services are connected, and your dev server is running. Now it is time to build the thing your users actually see.

The pipeline view is the first thing a business owner sees when they open the app. Building it first gives you an immediate visual payoff and the skeleton that every other feature plugs into.

In this step, get ready to:
  • Add the shadcn/ui components your dashboard needs (Card, Button, Badge).
  • Build a Kanban-style pipeline board with 5 status columns.
  • Populate the board with seed data so it looks alive immediately.
Add the shadcn/ui components your dashboard needs

The dashboard uses Card for lead tiles, Badge for status indicators and scores, and Button for actions. The shadcn/ui CLI adds the source code for each component directly into your project.

  • Install the three components you need by running this command:
npx shadcn@latest add button card badge

What does this command do?

The shadcn/ui CLI downloads the component source files into src/components/ui/. Unlike a traditional component library, you own these files and can customise them freely.

You should see each component added successfully in your terminal output.

Command not found?

Make sure you are running the command from inside your project folder (the one with package.json). If npx is not recognised, confirm Node.js is installed by running node --version.

help me fix a shadcn/ui component install error

Create the Lead type and pipeline board

Before the board can render, it needs to know what a "lead" looks like. You will define a TypeScript interface for leads, then build the board component that arranges them into columns.

  • Create a new file at src/hooks/useLeads.ts by adding this code:
export interface Lead {
  id: string;
  name: string;
  company: string;
  email: string;
  status: "new" | "qualifying" | "qualified" | "approved" | "rejected";
  ai_score: number | null;
  ai_assessment: string | null;
  override_score: number | null;
  override_note: string | null;
  created_at: string;
}

What does this interface do?

The Lead interface defines the shape of every lead in your pipeline. The status field is a union type that restricts values to exactly the five stages your Kanban board will display.

Fields like ai_score and override_score are nullable because a new lead has not been scored yet. The owner can override the AI's score later.

  • Save src/hooks/useLeads.ts.
  • Now create a new file at src/components/PipelineBoard.tsx and add the imports, column configuration, and type definition:
import { Lead } from "@/hooks/useLeads";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";

const COLUMNS = [
  { key: "new", label: "New", color: "bg-slate-500" },
  { key: "qualifying", label: "AI Qualifying", color: "bg-blue-500" },
  { key: "qualified", label: "Qualified", color: "bg-amber-500" },
  { key: "approved", label: "Approved", color: "bg-green-500" },
  { key: "rejected", label: "Rejected", color: "bg-red-500" },
] as const;

interface PipelineBoardProps {
  leads: Lead[];
  onLeadClick: (lead: Lead) => void;
}

What does this code do?

The COLUMNS array defines the five stages of your sales pipeline. Each column has a key that matches a lead's status value, a human-readable label, and a Tailwind color class for the count badge.

The as const assertion tells TypeScript to treat these values as literal types rather than generic strings. This gives you better autocomplete and type safety.

  • Below the interface in the same file, add the component function that renders the board:
export function PipelineBoard({ leads, onLeadClick }: PipelineBoardProps) {
  return (
    <div className="grid grid-cols-5 gap-4 h-full overflow-hidden">
      {COLUMNS.map((column) => {
        const columnLeads = leads.filter((l) => l.status === column.key);
        return (
          <div key={column.key} className="flex flex-col gap-3">
            <div className="flex items-center justify-between px-2">
              <h3 className="text-sm font-medium text-muted-foreground">
                {column.label}
              </h3>
              <Badge variant="secondary" className="text-xs">
                {columnLeads.length}
              </Badge>
            </div>
            <div className="flex flex-col gap-2 overflow-y-auto flex-1">
              {columnLeads.map((lead) => (
                <Card
                  key={lead.id}
                  className="cursor-pointer hover:border-primary/50 transition-colors"
                  onClick={() => onLeadClick(lead)}
                >
                  <CardContent className="p-3">
                    <p className="font-medium text-sm">{lead.name}</p>
                    <p className="text-xs text-muted-foreground">
                      {lead.company}
                    </p>
                    {lead.ai_score !== null && (
                      <Badge
                        variant={lead.ai_score >= 7 ? "default" : "secondary"}
                        className="mt-2 text-xs"
                      >
                        Score: {lead.override_score ?? lead.ai_score}/10
                      </Badge>
                    )}
                  </CardContent>
                </Card>
              ))}
            </div>
          </div>
        );
      })}
    </div>
  );
}

What does this component do?

The PipelineBoard renders a 5-column CSS grid. For each column, it filters leads by status and displays them as clickable Card components.

Each column header shows the stage name and a Badge with the lead count. Each lead card displays the person's name, company, and a score badge if the AI has scored them. The hover:border-primary/50 transition-colors class adds a subtle border highlight on hover.

  • Save src/components/PipelineBoard.tsx.
Wire up the app layout with seed data

The board component is ready. Now you need an app shell that renders it with some sample leads so you can see the full pipeline in action.

  • Replace the contents of src/App.tsx with the following code:
import { useState } from "react";
import { PipelineBoard } from "@/components/PipelineBoard";
import { Button } from "@/components/ui/button";

const SEED_LEADS = [
  { id: '1', name: 'Alice Johnson', company: 'Acme Corp', email: 'alice@acme.com', status: 'new', ai_score: null, ai_assessment: null, override_score: null, override_note: null, created_at: '' },
  { id: '2', name: 'Bob Smith', company: 'TechStart', email: 'bob@techstart.com', status: 'qualifying', ai_score: null, ai_assessment: null, override_score: null, override_note: null, created_at: '' },
  { id: '3', name: 'Carol White', company: 'BigCo', email: 'carol@bigco.com', status: 'qualified', ai_score: 8, ai_assessment: null, override_score: null, override_note: null, created_at: '' },
  { id: '4', name: 'Dan Brown', company: 'StartupX', email: 'dan@startupx.com', status: 'approved', ai_score: 9, ai_assessment: null, override_score: null, override_note: null, created_at: '' },
  { id: '5', name: 'Eve Davis', company: 'OldCorp', email: 'eve@oldcorp.com', status: 'rejected', ai_score: 3, ai_assessment: null, override_score: null, override_note: null, created_at: '' },
];

function App() {
  const [selectedLead, setSelectedLead] = useState(null);

  return (
    <div className="h-screen flex flex-col bg-background text-foreground">
      <header className="h-14 border-b border-border flex items-center justify-between px-6">
        <h1 className="text-lg font-semibold">AI Sales Manager</h1>
        <Button size="sm">New Lead</Button>
      </header>
      <main className="flex-1 p-6 overflow-hidden">
        <PipelineBoard leads={SEED_LEADS} onLeadClick={setSelectedLead} />
      </main>
    </div>
  );
}

export default App;

What does this code do?

The SEED_LEADS array gives you five fake leads spread across different pipeline stages. This lets you see the board in action immediately without needing a database connection yet.

The App component renders a header with the app title and a placeholder New Lead button. Below the header, the PipelineBoard fills the remaining screen height. The bg-background and text-foreground classes apply your dark theme automatically.

  • Save src/App.tsx.

Before you check the browser, what do you expect to see? Think about how the five seed leads are distributed across the columns.

  • Switch to your browser and check localhost:5173. Vite hot-reloads on save, so the page should already show your dashboard.

You should see a dark dashboard with a top header bar reading "AI Sales Manager" and a "New Lead" button on the right. Below, a 5-column Kanban board displays one lead card in each column. Cards for Carol, Dan, and Eve show colored score badges.

Seeing a blank page or error?

If you see a TypeScript error about missing imports, double check that you created src/hooks/useLeads.ts with the Lead interface export. The PipelineBoard component imports from this file.

If the styling looks wrong (no dark background, no borders), confirm that your Tailwind dark theme is configured. Your index.html should have class="dark" on the <html> tag.

help me debug why my PipelineBoard is not rendering

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/components/PipelineBoard.tsx and src/App.tsx.

ⓧ I'd like to double check the full code

Scroll up and confirm you have these files in order:

  • src/hooks/useLeads.ts with the Lead interface.
  • src/components/PipelineBoard.tsx with both code chunks (imports/COLUMNS/interface, then the PipelineBoard function).
  • src/App.tsx with the seed data and app layout.

If everything matches but the page still does not render, check your terminal for compile errors.

Your dashboard shell is alive. You have a premium dark-themed layout with a real pipeline board that responds to data. Next up, you will swap the hardcoded seed data for a live Supabase connection so leads persist and update in real time.

Connect Supabase and Load Real Data

Your pipeline board looks great, but right now it runs on hardcoded data. You cannot add, move, or persist leads. Without a real database connection, the pipeline is just a static screenshot.

In this step, you will wire up Supabase as a live backend. You will fetch real leads, subscribe to real-time changes so the board updates instantly, and build a form that inserts new leads directly into the database.

In this step, get ready to:
  • Create a useLeads hook that fetches leads from Supabase and subscribes to real-time updates.
  • Replace hardcoded seed data with live database queries.
  • Build a New Lead dialog form that inserts leads and shows them instantly via the real-time subscription.
Enable real-time replication on the leads table

For Supabase to push live updates to your app, the leads table needs real-time replication enabled. This tells Supabase to broadcast INSERT, UPDATE, and DELETE events to any connected client.

  • In your Supabase dashboard, open your project's Publications settings (under the Database section in the left sidebar).
  • Find the supabase_realtime publication.
  • Toggle on the leads table so it is included in the publication.

You should see the leads table listed as active under the publication. This confirms real-time events will fire for that table.

What is a publication?

A Supabase publication is a list of tables whose changes are broadcast over WebSockets. By adding your leads table to the supabase_realtime publication, every insert, update, or delete on that table will be pushed to any subscribed client in real time.

Create the useLeads hook

The hook centralizes all lead data access: fetching, real-time subscriptions, and mutations. It replaces the hardcoded seed array with live data.

  • Create a new file at src/hooks/useLeads.ts and paste the Lead interface and imports:
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";

export interface Lead {
  id: string;
  name: string;
  company: string;
  email: string;
  status: "new" | "qualifying" | "qualified" | "approved" | "rejected";
  ai_score: number | null;
  ai_assessment: string | null;
  override_score: number | null;
  override_note: string | null;
  created_at: string;
}

What does this code do?

The Lead interface defines the exact shape of a row in your Supabase leads table. TypeScript uses it to catch mismatches between your code and your database schema at compile time. The status field is a union of the five pipeline stages your Kanban board expects.

  • Below the interface in the same file, add the hook function that fetches leads and calls them on mount:
export function useLeads() {
  const [leads, setLeads] = useState<Lead[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchLeads() {
      const { data, error } = await supabase
        .from("leads")
        .select("*")
        .order("created_at", { ascending: false });

      if (!error && data) {
        setLeads(data as Lead[]);
      }
      setLoading(false);
    }

    fetchLeads();

What does this code do?

The useLeads hook initializes two state values: leads (an empty array) and loading (true until the first fetch completes). Inside the useEffect, fetchLeads queries Supabase for all rows in the leads table, ordered newest-first.

  • Still inside the useEffect (right after the fetchLeads(); call), add the real-time subscription channel:
    const channel = supabase
      .channel("leads-changes")
      .on(
        "postgres_changes",
        { event: "*", schema: "public", table: "leads" },
        (payload) => {
          if (payload.eventType === "INSERT") {
            setLeads((prev) => [payload.new as Lead, ...prev]);
          } else if (payload.eventType === "UPDATE") {
            setLeads((prev) =>
              prev.map((lead) =>
                lead.id === (payload.new as Lead).id
                  ? (payload.new as Lead)
                  : lead
              )
            );
          } else if (payload.eventType === "DELETE") {
            setLeads((prev) =>
              prev.filter((lead) => lead.id !== (payload.old as Lead).id)
            );
          }
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, []);

What does this code do?

This subscribes to the leads table's real-time events. When a row is inserted, the new lead is prepended to state. When a row is updated, the matching lead is replaced. When a row is deleted, it is removed from the array.

The cleanup function (return () => supabase.removeChannel(channel)) ensures the WebSocket connection is closed if the component unmounts.

  • Finally, below the useEffect closing bracket (but still inside useLeads), add the mutation functions and the return statement:
  async function addLead(name: string, company: string, email: string) {
    const { error } = await supabase
      .from("leads")
      .insert({ name, company, email, status: "new" });
    if (error) throw error;
  }

  async function updateLead(id: string, updates: Partial<Lead>) {
    const { error } = await supabase
      .from("leads")
      .update(updates)
      .eq("id", id);
    if (error) throw error;
  }

  return { leads, loading, addLead, updateLead };
}

What does this code do?

The addLead function inserts a new row into Supabase with a default status of "new". The updateLead function patches any fields on an existing lead by its id. Because the real-time subscription is already active, neither function needs to manually refresh state. Supabase pushes the change back through the channel automatically.

  • Save src/hooks/useLeads.ts.

Now update App.tsx to use this hook instead of the hardcoded seed data.

  • In src/App.tsx, find the import section at the top of the file. Find this line:
import { useState } from "react";
import { PipelineBoard } from "@/components/PipelineBoard";
import { Button } from "@/components/ui/button";
  • Replace those three lines with:
import { useState } from "react";
import { useLeads, Lead } from "@/hooks/useLeads";
import { PipelineBoard } from "@/components/PipelineBoard";
import { Button } from "@/components/ui/button";
  • Delete the entire SEED_LEADS array (all 7 lines starting with const SEED_LEADS = [ through the closing ];).
  • Inside the App function, find this line:
  const [selectedLead, setSelectedLead] = useState(null);
  • Replace it with these two lines:
  const { leads, loading, addLead, updateLead } = useLeads();
  const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
  • Add a loading guard above the return statement. Place this right after the useState line:
  if (loading) {
    return (
      <div className="h-screen flex items-center justify-center bg-background text-foreground">
        Loading...
      </div>
    );
  }
  • In the PipelineBoard component call, change leads={SEED_LEADS} to leads={leads}.
  • Save src/App.tsx.

Before you check the browser, what do you expect to see on the pipeline board? Think about it: you removed the seed data and the Supabase table is empty.

  • Switch to your browser at localhost:5173.

You should see the dark dashboard with the header and five empty pipeline columns, each showing a count badge of 0. The "Loading..." text flashes briefly, then the empty board appears. This confirms your hook is connecting to Supabase and returning an empty result set.

Seeing an error or blank screen?

  • Check the browser console for errors. A TypeError about supabase.from usually means VITE_SUPABASE_URL or VITE_SUPABASE_ANON_KEY is missing in your .env file.
  • If you see a TypeScript error about the Lead type, make sure your file is saved at exactly src/hooks/useLeads.ts (not src/hook/useLeads.ts).
  • Restart the dev server with Ctrl+C then npm run dev if environment variable changes are not picked up.

Still stuck? Why is my Supabase connection failing in the useLeads hook?

Insert test leads and verify real-time updates

Now you will prove that the pipeline renders live data and that real-time subscriptions work.

  • In the Supabase dashboard, open your leads table under Table Editor.
  • Click Insert row and add 3 test leads. For each, fill in name, company, email, and set status to one of: new, qualifying, or qualified. Leave other fields as null/default.
  • Switch to your browser (do NOT refresh the page).

You should see the lead cards appear in their correct pipeline columns without a page refresh. Each column's count badge updates to reflect the new leads. This is the real-time subscription at work.

  • Go back to the Supabase Table Editor and insert one more lead with status approved.
  • Switch to your browser again without refreshing.

The new card appears in the Approved column instantly. Your pipeline is now a living application.

Leads not appearing without a refresh?

  • Make sure you enabled real-time replication on the leads table in the Publications settings (the first action in this step).
  • Check the browser console for WebSocket errors. A 401 usually means your anon key is incorrect.
  • If leads appear only on refresh but not in real time, the supabase_realtime publication is likely missing the leads table toggle.

Still stuck? Why are my Supabase real-time updates not showing in the pipeline?

Add the New Lead dialog form

The final piece of this step is giving the owner a way to add leads directly from the app, instead of going through the Supabase dashboard every time.

  • Install the Dialog, Input, and Label components from shadcn/ui by running this command:
npx shadcn@latest add dialog input label

What does this command do?

This adds the source code for three new UI components into your src/components/ui/ folder: a modal Dialog, a styled Input, and an accessible Label. You own the code and can customize it later.

  • Now replace the entire contents of src/App.tsx with the following code. First, paste the imports and state setup:
import { useState } from "react";
import { useLeads, Lead } from "@/hooks/useLeads";
import { PipelineBoard } from "@/components/PipelineBoard";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

function App() {
  const { leads, loading, addLead, updateLead } = useLeads();
  const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
  const [dialogOpen, setDialogOpen] = useState(false);
  const [newLead, setNewLead] = useState({ name: "", company: "", email: "" });

What does this code do?

The imports bring in the Dialog components (modal overlay, header, trigger) plus Input and Label for the form fields. Three state variables track the dialog visibility, the form values, and which lead is selected for the side panel (used later).

  • Below the state declarations in the same file, paste the handler function and the loading guard:
  async function handleAddLead() {
    if (!newLead.name || !newLead.company || !newLead.email) return;
    await addLead(newLead.name, newLead.company, newLead.email);
    setNewLead({ name: "", company: "", email: "" });
    setDialogOpen(false);
  }

  if (loading) {
    return (
      <div className="h-screen flex items-center justify-center bg-background text-foreground">
        Loading...
      </div>
    );
  }

What does this code do?

The handleAddLead function validates that all three fields are filled, calls addLead (which inserts into Supabase), resets the form, and closes the dialog. The loading guard shows a centered message while the initial fetch is in progress.

  • Finally, paste the JSX return statement that renders the header with the Dialog and the pipeline board:
  return (
    <div className="h-screen flex flex-col bg-background text-foreground">
      <header className="h-14 border-b border-border flex items-center justify-between px-6">
        <h1 className="text-lg font-semibold">AI Sales Manager</h1>
        <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
          <DialogTrigger asChild>
            <Button size="sm">New Lead</Button>
          </DialogTrigger>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>Add New Lead</DialogTitle>
            </DialogHeader>
            <div className="space-y-4 pt-4">
              <div>
                <Label htmlFor="name">Name</Label>
                <Input id="name" value={newLead.name} onChange={(e) => setNewLead({ ...newLead, name: e.target.value })} />
              </div>
              <div>
                <Label htmlFor="company">Company</Label>
                <Input id="company" value={newLead.company} onChange={(e) => setNewLead({ ...newLead, company: e.target.value })} />
              </div>
              <div>
                <Label htmlFor="email">Email</Label>
                <Input id="email" type="email" value={newLead.email} onChange={(e) => setNewLead({ ...newLead, email: e.target.value })} />
              </div>
              <Button onClick={handleAddLead} className="w-full">Add Lead</Button>
            </div>
          </DialogContent>
        </Dialog>
      </header>
      <main className="flex-1 p-6 overflow-hidden">
        <PipelineBoard leads={leads} onLeadClick={setSelectedLead} />
      </main>
    </div>
  );
}

export default App;

What does this code do?

The Dialog wraps the New Lead button as its trigger. When clicked, a modal appears with three labeled inputs (Name, Company, Email) and an Add Lead submit button. The dialog's open state is controlled by dialogOpen so it closes automatically after submission.

  • Save src/App.tsx.

What do you think will happen when you fill in the form and click Add Lead? Will you need to refresh the page to see the new card?

  • Switch to your browser at localhost:5173.
  • Click the New Lead button in the header.
  • Fill in any name, company, and email.
  • Click Add Lead.

The dialog closes and a new lead card appears instantly in the New column. No page refresh needed. The real-time subscription receives the INSERT event from Supabase and prepends the lead to your state.

Dialog not opening or form not submitting?

  • If clicking New Lead does nothing, make sure you ran npx shadcn@latest add dialog input label and that src/components/ui/dialog.tsx exists.
  • If the form submits but no card appears, check the browser console for a Supabase permission error. The anon key needs INSERT access to the leads table (RLS policies must allow inserts, or RLS must be disabled for development).

Still stuck? Why is my New Lead dialog not inserting into Supabase?

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/hooks/useLeads.ts and src/App.tsx.

ⓧ I'd like to double check the full code

Here is the full src/hooks/useLeads.ts. Scroll through and confirm each section is present in your file:

  • Imports and Lead interface (lines 1-15).
  • useLeads function with fetchLeads (lines 17-34).
  • Real-time channel subscription with INSERT/UPDATE/DELETE handlers (lines 36-64).
  • addLead and updateLead functions, then the return statement (lines 66-82).

Here is the full src/App.tsx. Confirm yours matches:

  • Imports including useLeads, Lead, Dialog, Input, Label (lines 1-13).
  • State declarations: leads/loading from useLeads, selectedLead, dialogOpen, newLead (lines 15-19).
  • handleAddLead function with validation, addLead call, reset, and close (lines 21-26).
  • Loading guard returning centered text (lines 28-33).
  • Return JSX: header with Dialog/DialogTrigger wrapping the Button, DialogContent with three labeled Inputs and submit Button, then PipelineBoard in main (lines 35-end).

Your pipeline is now powered by live data. Leads persist across page refreshes, appear instantly when added, and can be managed from the Supabase dashboard or directly from your app. Next up, you will build the AI qualification chat that makes this more than just a pretty CRM board.

Build the AI Qualification Chat

Your pipeline board looks great, but right now it is just a visual display. Without an AI chat, this is just another CRM board.

The chat panel is where the "AI employee" concept comes alive. In this step, you will build a slide-over panel that opens when you click a lead card, and connect it to Groq's Llama 3.3 model so the AI can qualify leads through a real conversation.

In this step, get ready to:
  • Update the Groq module with a sales qualification system prompt and a reusable AI response function.
  • Create a LeadPanel component with a chat interface that slides in from the right.
  • Wire the panel into your app and test a full AI qualification conversation.
Add the AI qualification function

Your existing src/lib/groq.ts file initializes the Groq client but does not export a conversation function yet. You need a system prompt that teaches the AI how to qualify leads, and a reusable function your chat panel can call.

  • Open src/lib/groq.ts in VS Code.
  • Replace the entire contents of the file with the following code that adds a system prompt:
import Groq from "groq-sdk";

const groq = new Groq({
  apiKey: import.meta.env.VITE_GROQ_API_KEY,
  dangerouslyAllowBrowser: true,
});

const SYSTEM_PROMPT = `You are an AI Sales Qualification Agent. Your job is to qualify leads by asking smart questions about:
1. Budget - What is their budget range for this solution?
2. Authority - Are they the decision maker or do they need approval?
3. Need - What specific pain points are they trying to solve?
4. Timeline - When do they need a solution implemented?

Ask ONE question at a time. Be conversational and professional. After gathering enough information (usually 3-4 questions), provide a qualification score from 1-10 and a brief assessment.

Format your final assessment as:
QUALIFICATION COMPLETE
Score: [1-10]
Assessment: [one paragraph summary]
Recommendation: [Approve/Reject/Needs Follow-up]`;

What is a system prompt?

The SYSTEM_PROMPT is a hidden instruction that shapes how the AI behaves. It never appears in the chat. The lead and the owner only see the AI's responses.

This prompt teaches the AI the BANT framework (Budget, Authority, Need, Timeline) used by real sales teams to qualify prospects. It also enforces a structured output format so your code can detect when qualification is complete.

  • Add the getAIResponse function below the SYSTEM_PROMPT constant:
export async function getAIResponse(messages: { role: string; content: string }[]) {
  const completion = await groq.chat.completions.create({
    model: "llama-3.3-70b-versatile",
    messages: [
      { role: "system", content: SYSTEM_PROMPT },
      ...messages,
    ],
    temperature: 0.7,
    max_tokens: 500,
  });

  return completion.choices[0]?.message?.content || "";
}

What does this function do?

Every time your app calls getAIResponse, it sends the full conversation history plus the system prompt to Groq. The llama-3.3-70b-versatile model reads the entire context and returns its next response.

Setting temperature: 0.7 keeps the AI creative enough to ask varied questions without going off-topic. max_tokens: 500 prevents the AI from writing overly long responses.

  • Save src/lib/groq.ts.
  • Check your terminal where npm run dev is running. You should see no new errors.

Seeing a TypeScript error?

Make sure you removed the old export { groq }; line at the bottom of the file. The new file only exports getAIResponse.

If you see a module resolution error, confirm groq-sdk is installed by checking your package.json.

Help me debug my groq.ts file

Build the chat interface

Now you will create the LeadPanel component. This is the slide-over panel that appears when you click a lead card, showing the lead's details and a chat area where the AI qualification happens.

  • Create a new file at src/components/LeadPanel.tsx.
  • Add the imports and type definitions at the top of the file:
import { useState, useRef, useEffect } from "react";
import { Lead } from "@/hooks/useLeads";
import { getAIResponse } from "@/lib/groq";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";

interface Message {
  role: "user" | "assistant";
  content: string;
}

interface LeadPanelProps {
  lead: Lead;
  onClose: () => void;
  onUpdateLead: (id: string, updates: Partial<Lead>) => Promise<void>;
}

What are these types?

The Message interface tracks each chat bubble. Messages are either from the assistant (the AI) or the user (the lead's responses that the owner types on their behalf).

The LeadPanelProps interface defines what the parent component passes in: the lead to display, a close handler, and a function to update the lead in Supabase.

  • Below the interfaces, add the component declaration with its state and auto-scroll effect:
export function LeadPanel({ lead, onClose, onUpdateLead }: LeadPanelProps) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const messagesEndRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);
  • Add the startQualification function below the useEffect:
  async function startQualification() {
    setIsLoading(true);
    await onUpdateLead(lead.id, { status: "qualifying" });
    const initialMessage: Message = {
      role: "user",
      content: `New lead to qualify: ${lead.name} from ${lead.company} (${lead.email}). Please begin the qualification process.`,
    };
    const response = await getAIResponse([initialMessage]);
    setMessages([{ role: "assistant", content: response }]);
    setIsLoading(false);
  }

What does startQualification do?

When the owner clicks "Start Qualification," this function immediately moves the lead's status to qualifying in Supabase. The Kanban board updates in real-time thanks to the subscription you built earlier.

It then sends the lead's details to the AI as context, so the AI knows who it is qualifying before asking its first question.

  • Add the sendMessage function directly below startQualification:
  async function sendMessage() {
    if (!input.trim() || isLoading) return;
    const userMessage: Message = { role: "user", content: input };
    const updatedMessages = [...messages, userMessage];
    setMessages(updatedMessages);
    setInput("");
    setIsLoading(true);
    const response = await getAIResponse(updatedMessages);
    setMessages([...updatedMessages, { role: "assistant", content: response }]);
    if (response.includes("QUALIFICATION COMPLETE")) {
      const scoreMatch = response.match(/Score:\s*(\d+)/);
      const assessmentMatch = response.match(/Assessment:\s*(.+?)(?=Recommendation:|$)/s);
      if (scoreMatch) {
        await onUpdateLead(lead.id, {
          status: "qualified",
          ai_score: parseInt(scoreMatch[1]),
          ai_assessment: assessmentMatch?.[1]?.trim() || null,
        });
      }
    }
    setIsLoading(false);
  }

How does qualification detection work?

After each AI response, the code checks if the response contains the string QUALIFICATION COMPLETE. This is the marker you defined in the system prompt.

When detected, it uses regex to extract the numeric score and the assessment text, then saves both to Supabase. The lead's status moves to qualified and the Kanban board updates automatically.

  • Save src/components/LeadPanel.tsx and check the terminal for compile errors before continuing.
  • Now add the return statement with the panel header and lead status section:
  return (
    <div className="fixed inset-y-0 right-0 w-[480px] bg-card border-l border-border shadow-xl flex flex-col z-50">
      <div className="p-4 border-b border-border flex items-center justify-between">
        <div>
          <h2 className="font-semibold">{lead.name}</h2>
          <p className="text-sm text-muted-foreground">{lead.company}</p>
        </div>
        <Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
      </div>
      <div className="p-4 border-b border-border">
        <div className="flex items-center gap-2">
          <Badge>{lead.status}</Badge>
          {lead.ai_score !== null && (
            <Badge variant="secondary">AI Score: {lead.ai_score}/10</Badge>
          )}
        </div>
        {lead.ai_assessment && (
          <Card className="mt-3">
            <CardContent className="p-3 text-sm">{lead.ai_assessment}</CardContent>
          </Card>
        )}
      </div>
  • Add the scrollable chat message area directly below:
      <div className="flex-1 overflow-y-auto p-4 space-y-3">
        {messages.length === 0 && lead.status === "new" && (
          <div className="flex items-center justify-center h-full">
            <Button onClick={startQualification} disabled={isLoading}>Start Qualification</Button>
          </div>
        )}
        {messages.map((msg, i) => (
          <div key={i} className={`flex ${msg.role === "assistant" ? "justify-start" : "justify-end"}`}>
            <div className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
              msg.role === "assistant" ? "bg-muted text-muted-foreground" : "bg-primary text-primary-foreground"
            }`}>
              {msg.content}
            </div>
          </div>
        ))}
        {isLoading && (
          <div className="flex justify-start">
            <div className="bg-muted rounded-lg px-3 py-2 text-sm text-muted-foreground">Thinking...</div>
          </div>
        )}
        <div ref={messagesEndRef} />
      </div>

How does the chat styling work?

AI messages are left-aligned with a muted background (bg-muted). User messages are right-aligned with your primary accent color (bg-primary). This creates the familiar chat bubble pattern you see in messaging apps.

When the AI is processing, a "Thinking..." bubble appears so the owner knows a response is on its way.

  • Add the text input area and close the component:
      <div className="p-4 border-t border-border space-y-3">
        <div className="flex gap-2">
          <Input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && sendMessage()}
            placeholder="Type a response..."
            disabled={isLoading || messages.length === 0}
          />
          <Button onClick={sendMessage} disabled={isLoading || !input.trim()}>Send</Button>
        </div>
      </div>
    </div>
  );
}
  • Save src/components/LeadPanel.tsx.
  • Check your terminal. You should see no compile errors.

Seeing a compile error in LeadPanel?

The most common issue is a missing closing bracket or brace. Make sure the final line of your file is a single } closing the LeadPanel function.

If you see an error about getAIResponse not being exported, go back to src/lib/groq.ts and confirm the function has the export keyword.

Help me fix my LeadPanel.tsx compile error

Wire the panel into your app and test

The LeadPanel is ready but your app does not render it yet. You need to wire it into App.tsx so clicking a lead card actually opens the panel.

  • Open src/App.tsx.
  • Find the import section at the top. Add this import below the PipelineBoard import:
import { LeadPanel } from "@/components/LeadPanel";
  • Scroll down to the bottom of the component's return statement. Find the closing </main> tag.
  • Add the following code between </main> and the final </div>:
      {selectedLead && (
        <LeadPanel lead={selectedLead} onClose={() => setSelectedLead(null)} onUpdateLead={updateLead} />
      )}

What does this conditional render do?

When selectedLead is null, nothing renders. The moment you click a lead card (which calls setSelectedLead), React renders the LeadPanel with that lead's data. Clicking "Close" sets the state back to null and the panel disappears.

  • Save src/App.tsx.

Before you test, what do you think will happen when you click a lead card in the New column?

  • Switch to your browser at localhost:5173.
  • Click any lead card in the **New** column.

A panel slides in from the right showing the lead's name, company, and a "Start Qualification" button in the center.

  • Click **Start Qualification**.

The AI sends its first message asking about the lead's needs or budget. The lead card on the Kanban board moves to the "AI Qualifying" column in real-time.

  • Type a response in the input field (for example: We have a budget of $50k and need something by Q3) and press **Enter**.

The AI follows up with another question, continuing the BANT qualification process.

Speed up qualification for testing

To see the full qualification loop faster, give the AI detailed answers that cover multiple BANT criteria at once. After 3-4 exchanges, the AI should provide its final assessment with a score and recommendation.

  • Continue the conversation for 3-4 messages until the AI responds with QUALIFICATION COMPLETE followed by a score.
  • Check the Kanban board. The lead card should now appear in the **Qualified** column with a score badge.

Panel not opening when you click a lead?

Confirm you added the LeadPanel import at the top of App.tsx. Then check that the conditional render is placed after </main> but still inside the outer <div>.

If the panel opens but clicking Start Qualification shows no response, open your browser's DevTools console. Look for a network error or a 401 status. This usually means your VITE_GROQ_API_KEY in .env is missing or invalid.

Help me debug my AI qualification chat

Why dangerouslyAllowBrowser?

The Groq SDK disables browser usage by default because it exposes your API key in client-side code. Setting dangerouslyAllowBrowser: true overrides this for development.

This is acceptable for a local development project. In production, you would proxy requests through a server-side function to keep your key hidden.

✔️ Awesome, I've got everything!

Great. Make sure both src/lib/groq.ts and src/components/LeadPanel.tsx are saved.

ⓧ I'd like to double check the full code

Here is the complete src/lib/groq.ts. Confirm your file matches this exactly:

import Groq from "groq-sdk";

const groq = new Groq({
  apiKey: import.meta.env.VITE_GROQ_API_KEY,
  dangerouslyAllowBrowser: true,
});

const SYSTEM_PROMPT = `You are an AI Sales Qualification Agent. Your job is to qualify leads by asking smart questions about:
1. Budget - What is their budget range for this solution?
2. Authority - Are they the decision maker or do they need approval?
3. Need - What specific pain points are they trying to solve?
4. Timeline - When do they need a solution implemented?

Ask ONE question at a time. Be conversational and professional. After gathering enough information (usually 3-4 questions), provide a qualification score from 1-10 and a brief assessment.

Format your final assessment as:
QUALIFICATION COMPLETE
Score: [1-10]
Assessment: [one paragraph summary]
Recommendation: [Approve/Reject/Needs Follow-up]`;

export async function getAIResponse(messages: { role: string; content: string }[]) {
  const completion = await groq.chat.completions.create({
    model: "llama-3.3-70b-versatile",
    messages: [
      { role: "system", content: SYSTEM_PROMPT },
      ...messages,
    ],
    temperature: 0.7,
    max_tokens: 500,
  });

  return completion.choices[0]?.message?.content || "";
}

For src/components/LeadPanel.tsx, scroll up and confirm you have these sections in order:

  • Imports (useState, useRef, useEffect, Lead, getAIResponse, shadcn components).
  • The Message and LeadPanelProps interfaces.
  • The LeadPanel function with state declarations and the useEffect for auto-scroll.
  • The startQualification function.
  • The sendMessage function with the QUALIFICATION COMPLETE detection logic.
  • The return JSX: panel header, status section, scrollable chat area, and text input.

And the two changes to src/App.tsx. The import at the top:

import { LeadPanel } from "@/components/LeadPanel";

And the conditional render after </main>:

      {selectedLead && (
        <LeadPanel lead={selectedLead} onClose={() => setSelectedLead(null)} onUpdateLead={updateLead} />
      )}

Your AI Sales Manager now has a working brain. The AI can hold a conversation, ask the right questions, and score leads automatically. Next up, you will give the business owner the power to approve, reject, or override the AI's decisions.

Add Owner Controls — Approve, Reject, Override

Your AI is now qualifying leads, scoring them, and writing assessments. But an AI that acts without oversight is not an "AI employee." It is a liability.

In this step, you will add the controls that make this a true human-in-the-loop system. The business owner can approve, reject, or override any AI decision. The AI recommends, the owner decides.

In this step, get ready to:
  • Add Approve and Reject buttons that move lead cards through the pipeline.
  • Build an Override Score feature where the owner can override the AI's judgement.
  • Verify the full owner decision flow from AI assessment to final verdict.
Add the owner control handlers

The panel currently lets you chat with leads, but there is no way to act on the AI's recommendation. You need three handler functions: one to approve, one to reject, and one to override the AI's score.

  • In src/components/LeadPanel.tsx, find the existing state declarations at the top of the LeadPanel component (the lines with useState).
  • Add these three new state variables directly below the existing messagesEndRef line:
// Override feature state
const [overrideScore, setOverrideScore] = useState("");
const [overrideNote, setOverrideNote] = useState("");
const [showOverride, setShowOverride] = useState(false);
  • Now add the three handler functions below the existing sendMessage function:
// Approve moves the lead to the Approved column and closes the panel
async function handleApprove() {
  await onUpdateLead(lead.id, { status: "approved" });
  onClose();
}

// Reject moves the lead to the Rejected column and closes the panel
async function handleReject() {
  await onUpdateLead(lead.id, { status: "rejected" });
  onClose();
}

// Override lets the owner set a manual score and reason
async function handleOverride() {
  const score = parseInt(overrideScore);
  if (isNaN(score) || score < 1 || score > 10) return;
  await onUpdateLead(lead.id, { override_score: score, override_note: overrideNote });
  setShowOverride(false);
}

What do these handlers do?

Each handler calls onUpdateLead to update the lead record in Supabase. Because the useLeads hook subscribes to real-time changes, the Kanban board updates instantly when a status changes.

The handleOverride function validates that the score is between 1 and 10 before saving. It writes both override_score and override_note to the lead record so the owner's reasoning is preserved.

  • Save LeadPanel.tsx.
  • Check your browser. There are no visible changes yet because the handler functions are not wired to any buttons. You will add the buttons next.
Wire up the action bar and override popover

The handlers are ready. Now you need the UI: an action bar with Approve, Reject, and Override buttons that only appears when the lead is in a qualifying or qualified state.

  • In LeadPanel.tsx, scroll to the bottom of the return statement. Find the closing </div> of the input area (the div containing the text input and Send button).
  • Add the following action bar and override popover directly after the input's closing </div>, inside the parent border-t div:
{(lead.status === "qualified" || lead.status === "qualifying") && (
  <div className="flex gap-2">
    <Button className="flex-1 bg-green-600 hover:bg-green-700" onClick={handleApprove}>Approve</Button>
    <Button className="flex-1 bg-red-600 hover:bg-red-700" onClick={handleReject}>Reject</Button>
    <Button variant="outline" className="border-amber-500 text-amber-500" onClick={() => setShowOverride(!showOverride)}>Override</Button>
  </div>
)}
{showOverride && (
  <div className="space-y-2 p-3 border border-border rounded-lg">
    <Input type="number" min={1} max={10} placeholder="Score (1-10)" value={overrideScore} onChange={(e) => setOverrideScore(e.target.value)} />
    <Input placeholder="Reason for override..." value={overrideNote} onChange={(e) => setOverrideNote(e.target.value)} />
    <Button size="sm" onClick={handleOverride} className="w-full">Save Override</Button>
  </div>
)}

What does this UI do?

The action bar only renders when lead.status is "qualifying" or "qualified". A brand new lead has no decisions to make yet, and an already-approved lead does not need the buttons again.

The Override button toggles a small form where the owner enters a score from 1 to 10 and a reason. This data saves to override_score and override_note on the lead record. The PipelineBoard already displays the override score when present (using the ?? operator to prefer it over ai_score).

  • Save LeadPanel.tsx and switch to your browser.
  • Click a lead that has a status of "qualifying" or "qualified" in your pipeline.
  • You should see a green Approve button, a red Reject button, and an amber Override button below the chat input.

Don't see the action buttons?

The buttons only appear when the lead's status is "qualifying" or "qualified". If you click a lead with status "new", you will not see them. Start a qualification first, or check your Supabase dashboard and manually set a lead's status to qualified for testing.

Also confirm the new JSX is placed inside the border-t parent div, after the input row's closing </div>.

Help me debug my action bar buttons not appearing.

Verify the full owner decision flow

The qualification summary card (displaying the AI's assessment and score) is already in place from the previous step. It appears between the lead details and the chat when the AI completes qualification. This is the information surface the owner reads before clicking Approve, Reject, or Override.

Why show the AI's assessment before the controls?

The owner should never make a blind decision. The assessment card shows a one-paragraph summary plus the AI's confidence score. The owner reads this context, optionally scrolls through the full conversation, and then makes an informed Approve, Reject, or Override choice.

Before you test, what do you expect to happen when you click Approve on a qualified lead? Think about which column the card should move to and what happens to the panel.

  • Click a lead that has been qualified by the AI (status is "qualified").
  • Click the green Approve button.
  • The panel closes and the lead card moves to the Approved column in real time.
  • Click another qualifying or qualified lead and click the red Reject button. The card moves to the Rejected column.
  • Open a lead and click Override. Enter a score (e.g. 9) and a reason (e.g. "Known enterprise buyer"). Click Save Override. The score badge on the lead card updates to show your override.

Lead card not moving columns?

Make sure real-time replication is still enabled on your leads table in Supabase. Go to your Supabase dashboard, open the leads table, and confirm the real-time toggle is on.

If the override score does not save, check that your override_score and override_note columns exist in your Supabase leads table. Both should be nullable.

Help me debug my approve/reject/override controls not updating leads.

✔️ Awesome, I've got everything!

Great. Double check you have saved LeadPanel.tsx. Your owner controls are fully wired up.

ⓧ I'd like to double check the full code

The full LeadPanel.tsx is long, so scroll through your file and confirm these sections are present in order:

  • Imports at the top (useState, useRef, useEffect, Lead, getAIResponse, Button, Input, Badge, Card, CardContent).
  • The Message interface and LeadPanelProps interface.
  • Six useState declarations: messages, input, isLoading, overrideScore, overrideNote, and showOverride.
  • Functions: startQualification, sendMessage, handleApprove, handleReject, handleOverride.
  • The JSX return with: header, lead details + assessment card, chat messages area, input row, action buttons (conditional on status), and override popover (conditional on showOverride).

Your AI Sales Manager is now feature-complete. The AI qualifies leads, recommends decisions, and the owner has full control to approve, reject, or override at any point. This is a real human-in-the-loop AI system. Ready to push it further? The Secret Mission adds analytics so you can measure how well the AI is actually performing.

Secret mission

AI Performance Analytics Panel

Your AI Sales Manager qualifies leads and lets you approve or reject them. But how do you know if the AI is actually good at its job? Build an analytics panel that tracks qualification accuracy, approval rates, and pipeline distribution so you can measure your AI employee's performance over time.

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 uses Groq and Supabase free tiers, so there are no ongoing costs as long as you stay within their limits.

Resources you used:

  • Supabase project (free tier: 500MB database, 50,000 monthly active users).
  • Groq API key (free tier: 1,000 requests/day for llama-3.3-70b-versatile).
  • Local project folder (my-app on your Desktop).

Keep everything running

No action needed. Choose this if you plan to keep building on this project.

  • Both Groq and Supabase run on free tiers indefinitely with no charges.
  • Your Supabase project will auto-pause after 7 days of inactivity on the free tier. Resume it from the Supabase dashboard when you are ready to continue.
  • Your local project folder remains on your Desktop.

Pause - I'll come back to this later

Free up local resources while keeping your cloud services intact.

  • Stop the Vite dev server in your terminal by pressing Ctrl+C.
  • Your Supabase project will auto-pause after 7 days of inactivity on the free tier. No manual action needed.
  • Your Groq API key stays valid. No action needed.
  • When you return, run npm run dev from your project folder to start the dev server again. Resume the Supabase project from the dashboard if it was paused.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Delete your Supabase project: open your project in the Supabase dashboard, navigate to Settings then General then Delete project. Click Delete Project and confirm by typing your project name.
  • Revoke your Groq API key: go to console.groq.com/keys and delete the key you created for this project.
  • Delete your local project folder by running the command for your operating system.

On macOS or Linux:

rm -rf ~/Desktop/my-app

On Windows (PowerShell):

Remove-Item -Recurse -Force ~/Desktop/my-app

That's a Wrap!

That's a Wrap!

Nice work. You just built a fully functional AI Sales Manager command center that qualifies leads through conversation, displays them in a live pipeline, and keeps you in control of every decision.

You've learned how to:

  • Build a premium dark-themed SaaS dashboard with a Kanban-style lead pipeline using React, TypeScript, Tailwind CSS, and shadcn/ui.
  • Connect a real-time Supabase backend with PostgreSQL for persistent lead storage and instant UI updates via real-time subscriptions.
  • Integrate Groq's Llama 3.3 70B model to power an AI qualification chat with human-in-the-loop owner controls (approve, reject, override) that keep the business owner in charge of every AI decision.
  • Secret Mission: Built an analytics panel that tracks AI performance metrics and pipeline health across all lead stages.

Ready to quiz yourself?