Skip to main content
RapidDev - Software Development Agency

How to Build a Inventory Tracking Platform with Lovable

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.

What you'll build

  • Products and inventory_movements tables with computed current_stock Postgres views
  • Movements-based stock ledger where every change is an immutable record
  • Low-stock alert system using a Postgres trigger that inserts alert rows
  • Recharts line chart showing stock level trends over time per product
  • Product catalog DataTable with search, category filter, and inline edit Sheet
  • Stock adjustment Dialog for recording receives, sales, adjustments, and returns
  • Alerts notification panel showing current low-stock and out-of-stock items
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read2–3 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
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.

What you're building

An inventory tracking platform in Lovable uses a movements-based stock ledger rather than updating a single quantity column. Every stock change — a receive, a sale, a manual adjustment — is inserted as a new inventory_movements row. Current stock for any product is the SUM of all movements (positive for receives, negative for sales). This approach gives you a complete, immutable history of every stock change and makes it easy to audit discrepancies.

A Postgres view called current_stock pre-computes the running total for each product. Supabase also has a trigger on inventory_movements that fires after each insert: if the new computed stock falls below the product's reorder_point threshold, it inserts a row into an inventory_alerts table. The React UI subscribes to Realtime changes on this table to show a notification badge immediately.

The trend chart reads the cumulative stock level at the end of each day from inventory_movements, grouped by product and date. Recharts renders this as a line chart so buyers can spot seasonal patterns or identify which products are consistently running low.

Final result

A full-featured inventory platform with movements-based tracking, low-stock alerts, trend charts, and a product catalog — all backed by Supabase PostgreSQL.

Tech stack

LovableFrontend
SupabaseDatabase, Triggers & RLS
RechartsStock Trend Charts
shadcn/uiUI Components
TanStack Table v8Product Catalog DataTable
date-fnsDate Formatting

Prerequisites

  • 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

Build steps

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.

prompt.txt
1Create an inventory tracking app with Supabase. Set up these tables:
2
3- categories: id, org_id, name, description, created_at
4
5- 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
6
7- 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())
8
9- 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
10
11Create a Postgres view called current_stock:
12SELECT product_id, SUM(quantity) as stock_level FROM inventory_movements GROUP BY product_id
13
14Create 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.
15
16Enable 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.

src/components/inventory/ProductCatalog.tsx
1import { useState } from 'react'
2import { useQuery } from '@tanstack/react-query'
3import { supabase } from '@/integrations/supabase/client'
4import { DataTable } from '@/components/ui/data-table'
5import { Input } from '@/components/ui/input'
6import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
7import { Badge } from '@/components/ui/badge'
8import { Button } from '@/components/ui/button'
9import { Plus } from 'lucide-react'
10import type { ColumnDef } from '@tanstack/react-table'
11
12type ProductRow = {
13 id: string; sku: string; name: string; category_id: string
14 unit_cost: number; selling_price: number; reorder_point: number; is_active: boolean
15 current_stock: number | null
16 categories: { name: string } | null
17}
18
19export function ProductCatalog() {
20 const [search, setSearch] = useState('')
21 const [category, setCategory] = useState('all')
22
23 const { data: products = [] } = useQuery<ProductRow[]>({
24 queryKey: ['products', search, category],
25 queryFn: async () => {
26 let q = supabase
27 .from('products')
28 .select('*, categories(name), current_stock:current_stock(stock_level)')
29 .eq('is_active', true)
30 .order('name')
31 if (search) q = q.ilike('name', `%${search}%`)
32 if (category !== 'all') q = q.eq('category_id', category)
33 const { data, error } = await q
34 if (error) throw error
35 return data as ProductRow[]
36 },
37 })
38
39 const columns: ColumnDef<ProductRow>[] = [
40 { accessorKey: 'sku', header: 'SKU', cell: ({ getValue }) => <code className="text-xs">{getValue() as string}</code> },
41 { accessorKey: 'name', header: 'Product' },
42 { accessorFn: (r) => r.categories?.name ?? '—', header: 'Category' },
43 { accessorKey: 'current_stock', header: 'In Stock', cell: ({ getValue, row }) => {
44 const stock = (getValue() as number) ?? 0
45 const low = stock <= row.original.reorder_point
46 return <Badge variant={stock === 0 ? 'destructive' : low ? 'secondary' : 'default'}>{stock}</Badge>
47 }},
48 { accessorKey: 'selling_price', header: 'Price', cell: ({ getValue }) => `$${(getValue() as number).toFixed(2)}` },
49 ]
50
51 return (
52 <div className="space-y-3">
53 <div className="flex items-center gap-2">
54 <Input placeholder="Search products..." value={search} onChange={(e) => setSearch(e.target.value)} className="max-w-xs" />
55 <Select value={category} onValueChange={setCategory}>
56 <SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
57 <SelectContent>
58 <SelectItem value="all">All Categories</SelectItem>
59 </SelectContent>
60 </Select>
61 <Button size="sm" className="ml-auto"><Plus className="mr-1 h-3 w-3" /> Add Product</Button>
62 </div>
63 <DataTable columns={columns} data={products} />
64 </div>
65 )
66}

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.

prompt.txt
1Build a StockAdjustmentDialog component at src/components/inventory/StockAdjustmentDialog.tsx.
2
3Requirements:
4- Props: product (id, name, current stock level), onClose callback
5- Show a shadcn/ui Dialog titled 'Adjust Stock — [product name]'
6- Form fields using react-hook-form + zod:
7 - movement_type: Select with options: Receive (+), Sale (-), Adjustment (+/-), Return (+)
8 - quantity: Input type number, min 1. For 'sale' and negative 'adjustment', the value will be negated before insert.
9 - unit_cost: Input type number, optional, shown only for 'receive' type
10 - reference_id: Input text, optional, placeholder 'PO-123 or Order-456'
11 - notes: Textarea optional
12- Show a preview line: 'Current stock: 42 → New stock: 47' that updates as the user types
13- On submit: insert into inventory_movements. Quantity should be positive for receive/return, negative for sale.
14- Show a success toast and close the Dialog on completion
15- 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.

prompt.txt
1Build a StockTrendChart component at src/components/inventory/StockTrendChart.tsx.
2
3Requirements:
4- Props: productId (string), productName (string), reorderPoint (number)
5- Call supabase.rpc('get_stock_history', { p_product_id: productId, p_days: 30 })
6- The function returns rows of { day: date, stock_level: number }
7- Render using recharts LineChart:
8 - XAxis dataKey='day' with date format 'MMM d'
9 - YAxis with domain [0, 'auto']
10 - Line for stock_level in indigo, dot=false, strokeWidth=2
11 - A ReferenceLine at y=reorderPoint with label='Reorder' stroke='#f59e0b' strokeDasharray='4 4'
12 - Tooltip showing the date and stock level
13 - ResponsiveContainer width='100%' height={200}
14- Render inside a shadcn/ui Card with the product name as CardTitle
15- Show a Skeleton while loading
16- 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.

prompt.txt
1Build a LowStockAlertsPanel component at src/components/inventory/LowStockAlertsPanel.tsx.
2
3Requirements:
4- Fetch unresolved alerts from inventory_alerts (resolved_at IS NULL) joined with products (name, sku)
5- Subscribe to Supabase Realtime INSERT events on inventory_alerts to add new alerts in real time
6- Render as a shadcn/ui Sheet that opens from a Bell icon Button in the app header
7- The Bell icon shows a red Badge with the unresolved alert count (hide Badge if count is 0)
8- Inside the Sheet:
9 - Title: 'Inventory Alerts'
10 - Group alerts by alert_type: 'Out of Stock' (stock = 0) at the top, 'Low Stock' below
11 - Each alert row: product name, SKU, current stock, reorder point, created_at relative time
12 - 'Order Now' Button per row: opens the StockAdjustmentDialog pre-filled with movement_type='receive' for that product
13 - 'Dismiss' Button: sets resolved_at = now() on the alert row and removes it from the list
14- 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

src/components/inventory/StockAdjustmentDialog.tsx
1import { useForm } from 'react-hook-form'
2import { zodResolver } from '@hookform/resolvers/zod'
3import { z } from 'zod'
4import { useMutation, useQueryClient } from '@tanstack/react-query'
5import { supabase } from '@/integrations/supabase/client'
6import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
7import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
8import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
9import { Input } from '@/components/ui/input'
10import { Textarea } from '@/components/ui/textarea'
11import { Button } from '@/components/ui/button'
12import { toast } from 'sonner'
13
14const schema = z.object({
15 movement_type: z.enum(['receive', 'sale', 'adjustment', 'return']),
16 quantity: z.coerce.number().int().min(1, 'Quantity must be at least 1'),
17 unit_cost: z.coerce.number().optional(),
18 reference_id: z.string().optional(),
19 notes: z.string().optional(),
20})
21type FormValues = z.infer<typeof schema>
22
23type Props = { product: { id: string; name: string; currentStock: number }; onClose: () => void }
24
25const NEGATIVE_TYPES = ['sale']
26
27export function StockAdjustmentDialog({ product, onClose }: Props) {
28 const qc = useQueryClient()
29 const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { movement_type: 'receive', quantity: 1 } })
30 const movementType = form.watch('movement_type')
31 const qty = form.watch('quantity') || 0
32 const delta = NEGATIVE_TYPES.includes(movementType) ? -qty : qty
33 const newStock = product.currentStock + delta
34
35 const mutation = useMutation({
36 mutationFn: async (values: FormValues) => {
37 const signedQty = NEGATIVE_TYPES.includes(values.movement_type) ? -values.quantity : values.quantity
38 const { error } = await supabase.from('inventory_movements').insert({
39 product_id: product.id,
40 movement_type: values.movement_type,
41 quantity: signedQty,
42 unit_cost: values.unit_cost ?? null,
43 reference_id: values.reference_id || null,
44 notes: values.notes || null,
45 })
46 if (error) throw error
47 },
48 onSuccess: () => {
49 qc.invalidateQueries({ queryKey: ['products'] })
50 toast.success('Stock updated')
51 onClose()
52 },
53 onError: (e) => toast.error((e as Error).message),
54 })
55
56 return (
57 <Dialog open onOpenChange={onClose}>
58 <DialogContent className="max-w-md">
59 <DialogHeader><DialogTitle>Adjust Stock {product.name}</DialogTitle></DialogHeader>
60 <Form {...form}>
61 <form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-4">
62 <FormField control={form.control} name="movement_type" render={({ field }) => (
63 <FormItem><FormLabel>Type</FormLabel><FormControl>
64 <Select value={field.value} onValueChange={field.onChange}>
65 <SelectTrigger><SelectValue /></SelectTrigger>
66 <SelectContent>
67 <SelectItem value="receive">Receive (+)</SelectItem>
68 <SelectItem value="sale">Sale ()</SelectItem>
69 <SelectItem value="adjustment">Adjustment (+)</SelectItem>
70 <SelectItem value="return">Return (+)</SelectItem>
71 </SelectContent>
72 </Select>
73 </FormControl></FormItem>
74 )} />
75 <FormField control={form.control} name="quantity" render={({ field }) => (
76 <FormItem><FormLabel>Quantity</FormLabel><FormControl><Input type="number" min={1} {...field} /></FormControl><FormMessage /></FormItem>
77 )} />
78 <p className="rounded bg-muted px-3 py-2 text-sm">
79 Current: <strong>{product.currentStock}</strong> New: <strong className={newStock < 0 ? 'text-red-600' : ''}>{newStock}</strong>
80 </p>
81 <FormField control={form.control} name="reference_id" render={({ field }) => (
82 <FormItem><FormLabel>Reference (optional)</FormLabel><FormControl><Input placeholder="PO-123" {...field} /></FormControl></FormItem>
83 )} />
84 <FormField control={form.control} name="notes" render={({ field }) => (
85 <FormItem><FormLabel>Notes (optional)</FormLabel><FormControl><Textarea rows={2} {...field} /></FormControl></FormItem>
86 )} />
87 <Button type="submit" className="w-full" disabled={mutation.isPending}>
88 {mutation.isPending ? 'Saving...' : 'Save Movement'}
89 </Button>
90 </form>
91 </Form>
92 </DialogContent>
93 </Dialog>
94 )
95}

Customization ideas

Barcode scanner support

Add an Input in the product catalog that accepts barcode scans (a barcode scanner types the code and presses Enter). On Enter, look up the product by SKU and open the stock adjustment Dialog pre-filled.

Supplier management

Add a suppliers table and link products to suppliers via a product_suppliers join table. Show the preferred supplier on the product Sheet and add a 'Create Purchase Order' button that generates a purchase order for all low-stock items.

Multi-location tracking

Add a locations table (warehouse, store, etc.) and a location_id column to inventory_movements. Extend the current_stock view to group by both product_id and location_id so you can see stock per location.

Inventory valuation report

Create a Postgres function that calculates inventory value using the weighted average cost method — summing (quantity * unit_cost) for all receive movements and dividing by total received quantity. Display total inventory value on the dashboard.

Expiry date tracking

Add an expiry_date column to inventory_movements for perishable items. Create a view that shows products with movements expiring in the next 30 days and add them to the alerts panel.

Reorder automation

Create a Supabase Edge Function that checks the inventory_alerts table daily and creates draft purchase orders in a purchase_orders table for all unresolved low-stock alerts, grouped by supplier.

Common pitfalls

Pitfall: Updating a quantity column directly instead of using movements

How to avoid: Always insert an inventory_movements row for every change. The current_stock view computes the live total from the ledger.

Pitfall: Querying the raw inventory_movements table to get current stock in React

How to avoid: Query the current_stock Postgres view which pre-aggregates the SUM server-side and returns a single row per product.

Pitfall: Inserting negative quantities for sales without sign normalization

How to avoid: In the form, always accept positive numbers. The mutation function negates the quantity for 'sale' and positive 'adjustment' movements before inserting.

Pitfall: Not creating an index on inventory_movements(product_id)

How to avoid: 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.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I have a Supabase inventory_movements table with columns: id, product_id, quantity (positive for receives, negative for sales), created_at. Help me write a Postgres function get_stock_history(p_product_id uuid, p_days integer) that returns one row per day showing the cumulative stock level at the end of each day for the last p_days days, including days with no movements (fill in the last known stock level).

Lovable Prompt

Add a purchase orders feature to the inventory tracking platform. Create a purchase_orders table (id, supplier_name, status: draft/sent/received, created_at) and purchase_order_items (id, purchase_order_id, product_id, quantity, unit_cost). Build a PurchaseOrders page with a DataTable listing orders and an order detail Sheet. When an order is marked 'received', automatically insert inventory_movements rows with movement_type='receive' for each line item.

Build Prompt

In Lovable, build an inventory dashboard overview page that shows: a summary row with total SKUs, total units in stock, stock value, and unresolved alert count; a top 10 fastest-moving products table (most movements in last 30 days); a top 10 slowest-moving products table (fewest movements); and the StockTrendChart for the 5 products with the lowest stock levels relative to their reorder points.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.