# How to Build an Inventory Tracking Platform with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build an inventory tracking platform in Lovable with a movements-based stock ledger, Supabase triggers for low-stock alerts, trend charts using Recharts, and a product catalog with category management. Every stock change is recorded as an immutable movement so you always have a full audit trail.

## Before you start

- Lovable Pro account
- Supabase project created at supabase.com with URL and anon key ready
- VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in Cloud tab → Secrets
- A list of product categories you manage (or use the defaults)
- Basic familiarity with Lovable's Cloud tab and the Publish flow

## Step-by-step guide

### 1. Scaffold the inventory schema with movements ledger

Ask Lovable to create the core tables including the movements ledger and a Postgres view that computes current stock. This view is what the UI queries — not the raw movements table.

```
Create an inventory tracking app with Supabase. Set up these tables:

- categories: id, org_id, name, description, created_at

- products: id, org_id, sku (unique), name, description, category_id, unit_cost (numeric), selling_price (numeric), reorder_point (integer default 10), reorder_quantity (integer default 50), image_url (text nullable), is_active (boolean default true), created_at, updated_at

- inventory_movements: id, org_id, product_id, movement_type ('receive'|'sale'|'adjustment'|'return'|'transfer'), quantity (integer, positive for stock in, negative for stock out), unit_cost (numeric nullable), reference_id (text nullable, e.g. order ID), notes (text nullable), created_by (uuid references auth.users), created_at (timestamptz default now())

- inventory_alerts: id, org_id, product_id, alert_type ('low_stock'|'out_of_stock'), current_stock (integer), reorder_point (integer), resolved_at (nullable timestamptz), created_at

Create a Postgres view called current_stock:
SELECT product_id, SUM(quantity) as stock_level FROM inventory_movements GROUP BY product_id

Create a trigger: after INSERT on inventory_movements, compute the new stock for product_id. If stock <= reorder_point and no unresolved alert exists, insert into inventory_alerts.

Enable RLS on all tables with org_id isolation. Seed with 20 sample products across 4 categories and 50 movements.
```

> Pro tip: Ask Lovable to also create a Postgres function get_stock_history(p_product_id uuid, p_days integer) that returns the cumulative stock level at the end of each day — this will power the trend chart without complex client-side calculation.

**Expected result:** All tables are created. The current_stock view is queryable. Inserting a movement row with a low quantity triggers the alert insert. Seed data populates products and movements.

### 2. Build the product catalog DataTable

Create the main product catalog with search, category filter, and a current stock column computed from the view. Clicking a row opens the product edit Sheet.

```
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { supabase } from '@/integrations/supabase/client'
import { DataTable } from '@/components/ui/data-table'
import { Input } from '@/components/ui/input'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import type { ColumnDef } from '@tanstack/react-table'

type ProductRow = {
  id: string; sku: string; name: string; category_id: string
  unit_cost: number; selling_price: number; reorder_point: number; is_active: boolean
  current_stock: number | null
  categories: { name: string } | null
}

export function ProductCatalog() {
  const [search, setSearch] = useState('')
  const [category, setCategory] = useState('all')

  const { data: products = [] } = useQuery<ProductRow[]>({
    queryKey: ['products', search, category],
    queryFn: async () => {
      let q = supabase
        .from('products')
        .select('*, categories(name), current_stock:current_stock(stock_level)')
        .eq('is_active', true)
        .order('name')
      if (search) q = q.ilike('name', `%${search}%`)
      if (category !== 'all') q = q.eq('category_id', category)
      const { data, error } = await q
      if (error) throw error
      return data as ProductRow[]
    },
  })

  const columns: ColumnDef<ProductRow>[] = [
    { accessorKey: 'sku', header: 'SKU', cell: ({ getValue }) => <code className="text-xs">{getValue() as string}</code> },
    { accessorKey: 'name', header: 'Product' },
    { accessorFn: (r) => r.categories?.name ?? '—', header: 'Category' },
    { accessorKey: 'current_stock', header: 'In Stock', cell: ({ getValue, row }) => {
      const stock = (getValue() as number) ?? 0
      const low = stock <= row.original.reorder_point
      return <Badge variant={stock === 0 ? 'destructive' : low ? 'secondary' : 'default'}>{stock}</Badge>
    }},
    { accessorKey: 'selling_price', header: 'Price', cell: ({ getValue }) => `$${(getValue() as number).toFixed(2)}` },
  ]

  return (
    <div className="space-y-3">
      <div className="flex items-center gap-2">
        <Input placeholder="Search products..." value={search} onChange={(e) => setSearch(e.target.value)} className="max-w-xs" />
        <Select value={category} onValueChange={setCategory}>
          <SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
          <SelectContent>
            <SelectItem value="all">All Categories</SelectItem>
          </SelectContent>
        </Select>
        <Button size="sm" className="ml-auto"><Plus className="mr-1 h-3 w-3" /> Add Product</Button>
      </div>
      <DataTable columns={columns} data={products} />
    </div>
  )
}
```

> Pro tip: Color-code the stock Badge: red for 0 (out of stock), yellow/secondary for at or below reorder_point, green for healthy. This gives buyers an instant visual scan of which products need attention.

**Expected result:** The product catalog shows all products with their current stock from the view. The stock badge changes color based on the reorder threshold. Search and category filter work in real time.

### 3. Build the stock adjustment Dialog

Create a Dialog for recording stock movements. Users pick the movement type, enter a quantity, and optionally add a reference number and notes. The movement is inserted and the view updates automatically.

```
Build a StockAdjustmentDialog component at src/components/inventory/StockAdjustmentDialog.tsx.

Requirements:
- Props: product (id, name, current stock level), onClose callback
- Show a shadcn/ui Dialog titled 'Adjust Stock — [product name]'
- Form fields using react-hook-form + zod:
  - movement_type: Select with options: Receive (+), Sale (-), Adjustment (+/-), Return (+)
  - quantity: Input type number, min 1. For 'sale' and negative 'adjustment', the value will be negated before insert.
  - unit_cost: Input type number, optional, shown only for 'receive' type
  - reference_id: Input text, optional, placeholder 'PO-123 or Order-456'
  - notes: Textarea optional
- Show a preview line: 'Current stock: 42 → New stock: 47' that updates as the user types
- On submit: insert into inventory_movements. Quantity should be positive for receive/return, negative for sale.
- Show a success toast and close the Dialog on completion
- Invalidate the ['products'] React Query cache on success
```

**Expected result:** The stock adjustment Dialog opens from a product row. The preview line updates live as the quantity is typed. Submitting inserts a movement row and the product's stock badge in the catalog updates.

### 4. Add the stock trend chart per product

Build a trend chart that shows the stock level at the end of each day for a selected product over the last 30 days. The data comes from the get_stock_history Postgres function.

```
Build a StockTrendChart component at src/components/inventory/StockTrendChart.tsx.

Requirements:
- Props: productId (string), productName (string), reorderPoint (number)
- Call supabase.rpc('get_stock_history', { p_product_id: productId, p_days: 30 })
- The function returns rows of { day: date, stock_level: number }
- Render using recharts LineChart:
  - XAxis dataKey='day' with date format 'MMM d'
  - YAxis with domain [0, 'auto']
  - Line for stock_level in indigo, dot=false, strokeWidth=2
  - A ReferenceLine at y=reorderPoint with label='Reorder' stroke='#f59e0b' strokeDasharray='4 4'
  - Tooltip showing the date and stock level
  - ResponsiveContainer width='100%' height={200}
- Render inside a shadcn/ui Card with the product name as CardTitle
- Show a Skeleton while loading
- If stock_level crosses below reorderPoint, shade that region in a light amber color using Recharts ReferenceArea
```

> Pro tip: The ReferenceLine at the reorder point is the most actionable feature of the chart — it makes it visually obvious how often and for how long stock dips below the reorder threshold, helping buyers decide whether to increase the reorder quantity.

**Expected result:** The stock trend chart shows 30 days of stock level history as a line. A dashed amber reference line marks the reorder threshold. Periods where stock was below the threshold are shaded.

### 5. Build the low-stock alerts panel

Create a notification panel that reads unresolved inventory_alerts and subscribes to Realtime inserts. A badge in the header shows the alert count.

```
Build a LowStockAlertsPanel component at src/components/inventory/LowStockAlertsPanel.tsx.

Requirements:
- Fetch unresolved alerts from inventory_alerts (resolved_at IS NULL) joined with products (name, sku)
- Subscribe to Supabase Realtime INSERT events on inventory_alerts to add new alerts in real time
- Render as a shadcn/ui Sheet that opens from a Bell icon Button in the app header
- The Bell icon shows a red Badge with the unresolved alert count (hide Badge if count is 0)
- Inside the Sheet:
  - Title: 'Inventory Alerts'
  - Group alerts by alert_type: 'Out of Stock' (stock = 0) at the top, 'Low Stock' below
  - Each alert row: product name, SKU, current stock, reorder point, created_at relative time
  - 'Order Now' Button per row: opens the StockAdjustmentDialog pre-filled with movement_type='receive' for that product
  - 'Dismiss' Button: sets resolved_at = now() on the alert row and removes it from the list
- Show empty state illustration when no unresolved alerts exist
```

**Expected result:** The Bell icon in the header shows a badge with the unresolved alert count. Opening the Sheet lists all low-stock and out-of-stock products. Clicking 'Order Now' opens the stock adjustment Dialog.

## Complete code example

File: `src/components/inventory/StockAdjustmentDialog.tsx`

```typescript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/integrations/supabase/client'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'

const schema = z.object({
  movement_type: z.enum(['receive', 'sale', 'adjustment', 'return']),
  quantity: z.coerce.number().int().min(1, 'Quantity must be at least 1'),
  unit_cost: z.coerce.number().optional(),
  reference_id: z.string().optional(),
  notes: z.string().optional(),
})
type FormValues = z.infer<typeof schema>

type Props = { product: { id: string; name: string; currentStock: number }; onClose: () => void }

const NEGATIVE_TYPES = ['sale']

export function StockAdjustmentDialog({ product, onClose }: Props) {
  const qc = useQueryClient()
  const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { movement_type: 'receive', quantity: 1 } })
  const movementType = form.watch('movement_type')
  const qty = form.watch('quantity') || 0
  const delta = NEGATIVE_TYPES.includes(movementType) ? -qty : qty
  const newStock = product.currentStock + delta

  const mutation = useMutation({
    mutationFn: async (values: FormValues) => {
      const signedQty = NEGATIVE_TYPES.includes(values.movement_type) ? -values.quantity : values.quantity
      const { error } = await supabase.from('inventory_movements').insert({
        product_id: product.id,
        movement_type: values.movement_type,
        quantity: signedQty,
        unit_cost: values.unit_cost ?? null,
        reference_id: values.reference_id || null,
        notes: values.notes || null,
      })
      if (error) throw error
    },
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['products'] })
      toast.success('Stock updated')
      onClose()
    },
    onError: (e) => toast.error((e as Error).message),
  })

  return (
    <Dialog open onOpenChange={onClose}>
      <DialogContent className="max-w-md">
        <DialogHeader><DialogTitle>Adjust Stock — {product.name}</DialogTitle></DialogHeader>
        <Form {...form}>
          <form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-4">
            <FormField control={form.control} name="movement_type" render={({ field }) => (
              <FormItem><FormLabel>Type</FormLabel><FormControl>
                <Select value={field.value} onValueChange={field.onChange}>
                  <SelectTrigger><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="receive">Receive (+)</SelectItem>
                    <SelectItem value="sale">Sale (−)</SelectItem>
                    <SelectItem value="adjustment">Adjustment (+)</SelectItem>
                    <SelectItem value="return">Return (+)</SelectItem>
                  </SelectContent>
                </Select>
              </FormControl></FormItem>
            )} />
            <FormField control={form.control} name="quantity" render={({ field }) => (
              <FormItem><FormLabel>Quantity</FormLabel><FormControl><Input type="number" min={1} {...field} /></FormControl><FormMessage /></FormItem>
            )} />
            <p className="rounded bg-muted px-3 py-2 text-sm">
              Current: <strong>{product.currentStock}</strong> → New: <strong className={newStock < 0 ? 'text-red-600' : ''}>{newStock}</strong>
            </p>
            <FormField control={form.control} name="reference_id" render={({ field }) => (
              <FormItem><FormLabel>Reference (optional)</FormLabel><FormControl><Input placeholder="PO-123" {...field} /></FormControl></FormItem>
            )} />
            <FormField control={form.control} name="notes" render={({ field }) => (
              <FormItem><FormLabel>Notes (optional)</FormLabel><FormControl><Textarea rows={2} {...field} /></FormControl></FormItem>
            )} />
            <Button type="submit" className="w-full" disabled={mutation.isPending}>
              {mutation.isPending ? 'Saving...' : 'Save Movement'}
            </Button>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  )
}
```

## Common mistakes

- **Updating a quantity column directly instead of using movements** — Direct quantity updates lose the history of why stock changed. You cannot audit discrepancies, calculate turnover rates, or recreate the stock level at any past point in time. Fix: Always insert an inventory_movements row for every change. The current_stock view computes the live total from the ledger.
- **Querying the raw inventory_movements table to get current stock in React** — Summing all movements client-side requires fetching potentially thousands of rows per product and doing the aggregation in JavaScript. Fix: Query the current_stock Postgres view which pre-aggregates the SUM server-side and returns a single row per product.
- **Inserting negative quantities for sales without sign normalization** — If the form allows negative quantity input and the code does not negate it for sales, double negatives result in stock increasing when a sale is recorded. Fix: In the form, always accept positive numbers. The mutation function negates the quantity for 'sale' and positive 'adjustment' movements before inserting.
- **Not creating an index on inventory_movements(product_id)** — The current_stock view aggregates all movements per product. Without an index on product_id, this becomes a full table scan as the movements table grows. Fix: Add CREATE INDEX idx_movements_product ON inventory_movements(product_id) in the Supabase SQL editor.

## Best practices

- Use a movements-based ledger rather than a mutable quantity column — it gives you a full audit trail, makes discrepancy investigation easy, and supports time-based stock level queries.
- Create the current_stock Postgres view as the single source of truth for stock levels in the UI — never sum movements client-side.
- Index inventory_movements on (product_id, created_at) to keep the current_stock view fast and the trend chart query efficient.
- Use a Postgres trigger for low-stock alerts rather than client-side checks — the trigger fires for all writes, including direct database changes from migrations or admin tools.
- Always validate that a stock adjustment would not produce negative inventory — show the preview calculation in the Dialog so users catch errors before saving.
- Enable RLS on inventory_movements and alerts with org_id isolation — inventory data is sensitive business information.
- Use coerce on zod number fields in forms to handle empty string to number conversion automatically.
- Add a unique constraint on products(org_id, sku) to prevent duplicate SKUs within an organization.

## Frequently asked questions

### Why use movements instead of just updating a quantity column?

A movements ledger gives you a complete, immutable history of every stock change. You can reconstruct the stock level at any point in time, audit discrepancies, calculate turnover rates, and identify which order or person caused a change. A simple quantity column loses all of this history.

### What happens to the current stock view when movements are deleted?

The view recomputes from all remaining movements. Deleting a movement row effectively reverses that stock change. For this reason, movements should never be deleted — use a 'reversal' movement (e.g. a positive adjustment to cancel a mistaken negative sale) instead.

### How do I handle stock transfers between locations?

Add a location_id column to inventory_movements and record transfers as two rows: one negative movement at the source location and one positive movement at the destination location, both with movement_type='transfer' and the same reference_id so you can link them.

### Can I import initial stock levels from a spreadsheet?

Yes. Insert an inventory_movements row per product with movement_type='receive' and the initial quantity. Use a shared reference_id like 'INITIAL_IMPORT_2024-01-01' so you can identify these rows in the audit trail. Ask Lovable to build a CSV import Dialog for bulk initial stock entry.

### How does the low-stock trigger avoid creating duplicate alerts?

The trigger checks for existing unresolved alerts before inserting: it only creates a new alert row if no row exists in inventory_alerts where product_id matches AND resolved_at IS NULL. This prevents alert spam when stock fluctuates around the threshold.

### Why is the trend chart showing flat lines instead of changes?

The get_stock_history function uses the cumulative SUM of movements to compute the end-of-day stock level. If you only have receive movements and no sales, the line will slope upward but never decrease. Add some sale movements to the test data to see the line go up and down.

### Can RapidDev help me add variant tracking (sizes, colors, etc.)?

Yes. RapidDev can help you extend the schema to support product variants, add variant-level stock movements, and update the catalog DataTable to expand product rows into their variants.

### Does this support negative stock (backorders)?

The movements table accepts negative cumulative stock levels — there is no database-level constraint preventing it. Add a zod validation in the StockAdjustmentDialog that shows a warning when the preview calculation would result in negative stock, and let the user decide whether to allow it.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/inventory-tracking-platform
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/inventory-tracking-platform
