Mini CRM
- Build a lightweight CRM with contacts, deals, pipeline stages, and activity tracking
Prerequisite
Complete the Get Started guide first.
A mini CRM is what any sales-driven business uses to manage relationships and track deals through a pipeline. Think agencies, freelancers, SaaS companies, real estate, or any business that needs to follow up with leads and close deals.
By the end of this example you will have:
- Contacts — full CRUD for people and companies with tags and notes
- Deals — opportunities with pipeline stages, values, and expected close dates
- Activity log — calls, emails, meetings, and notes linked to contacts or deals
- Pipeline dashboard — visual pipeline summary with deal values per stage
- Realtime — all changes sync instantly across connected browsers
Database Schema and Permissions
Prompt
txt
We are building a mini CRM app on top of this template. Create the database
schema and add the permissions we need.
Database (via Supabase MCP):
Create four tables:
1. `contacts` table — id (uuid, default gen_random_uuid(), primary key),
team_id (uuid, references teams(id) on delete cascade, not null),
name (text, not null), email (text, nullable), phone (text, nullable),
company (text, nullable),
type (text, check type in ('lead', 'prospect', 'customer', 'partner'),
default 'lead', not null),
tags (text[], default '{}', not null),
notes (text, nullable),
owner_id (uuid, references profiles(id) on delete set null, nullable),
created_by (uuid, references profiles(id), not null),
created_at (timestamptz, default now()),
updated_at (timestamptz, default now()).
2. `deals` table — id (uuid, default gen_random_uuid(), primary key),
team_id (uuid, references teams(id) on delete cascade, not null),
contact_id (uuid, references contacts(id) on delete cascade, not null),
title (text, not null),
value (numeric(12,2), nullable),
stage (text, check stage in ('qualification', 'proposal', 'negotiation',
'closed_won', 'closed_lost'), default 'qualification', not null),
priority (text, check priority in ('low', 'normal', 'high'), default
'normal', not null),
expected_close_date (date, nullable),
closed_date (date, nullable),
owner_id (uuid, references profiles(id) on delete set null, nullable),
notes (text, nullable),
created_by (uuid, references profiles(id), not null),
created_at (timestamptz, default now()),
updated_at (timestamptz, default now()).
3. `activities` table — id (uuid, default gen_random_uuid(), primary key),
team_id (uuid, references teams(id) on delete cascade, not null),
contact_id (uuid, references contacts(id) on delete cascade, nullable),
deal_id (uuid, references deals(id) on delete cascade, nullable),
type (text, check type in ('call', 'email', 'meeting', 'note', 'task'),
not null),
subject (text, not null),
description (text, nullable),
activity_date (timestamptz, default now(), not null),
created_by (uuid, references profiles(id), not null),
created_at (timestamptz, default now()).
4. `deal_notes` table — id (uuid, default gen_random_uuid(), primary key),
deal_id (uuid, references deals(id) on delete cascade, not null),
user_id (uuid, references profiles(id), not null),
content (text, not null),
created_at (timestamptz, default now()).
Enable RLS on all four tables. Create policies that use the existing
`is_team_member()` function to scope access. For `contacts` and `deals`,
the policy should check that the row's `team_id` matches a team the user
belongs to. For `activities`, check team_id directly. For `deal_notes`,
join through the `deals` table to check team membership.
All policies should allow select, insert, update, and delete for team
members. We will handle finer-grained permission checks in the server
routes, not in RLS.
After creating the tables, add these permissions to `shared/permissions.ts`:
```
"contacts.view": ["owner", "admin", "member"],
"contacts.create": ["owner", "admin", "member"],
"contacts.update": ["owner", "admin", "member"],
"contacts.delete": ["owner", "admin"],
"deals.view": ["owner", "admin", "member"],
"deals.create": ["owner", "admin", "member"],
"deals.update": ["owner", "admin", "member"],
"deals.delete": ["owner", "admin"],
"activities.view": ["owner", "admin", "member"],
"activities.create": ["owner", "admin", "member"],
```
Regenerate the TypeScript types via Supabase MCP and save them to
`shared/types/database.types.ts`.Adding plugins later?
If you plan to add API Keys & External API or AI Chat later, their pages will guide you through adding the required permissions. You don't need to add them now.
Contacts
Prompt
txt
Build the contacts module — server routes and a full UI page.
Server routes (all use `authUser` with the appropriate permission):
- `GET /api/teams/[teamId]/contacts` — uses
`authUser(event, "contacts.view")`. Returns all contacts for the team,
ordered by name asc. Include a count of deals per contact and the owner's
name by joining. Support optional query params: `?type=` to filter by
contact type, `?q=` to search by name, email, or company (use ILIKE).
- `POST /api/teams/[teamId]/contacts` — uses
`authUser(event, "contacts.create")`. Reads { name, email, phone, company,
type, tags, notes, owner_id } from the body. Validates that name is
required. Sets team_id from the auth context and created_by from the
authenticated user (user.sub).
- `PATCH /api/teams/[teamId]/contacts/[contactId]` — uses
`authUser(event, "contacts.update")`. Updates whichever fields are provided
in the body. Validates that the contact belongs to the team before updating.
- `DELETE /api/teams/[teamId]/contacts/[contactId]` — uses
`authUser(event, "contacts.delete")`. Validates that the contact belongs to
the team before deleting.
UI:
Create `app/pages/contacts/index.vue` — a contact list page as a top-level
route (not nested under `/dashboard`), wrapped in a `UDashboardPanel`:
- `UDashboardNavbar` in the header: title "Contacts",
`UDashboardSidebarCollapse` on the left. On the right, a "New Contact"
button wrapped in `CanAccess permission="contacts.create"`.
- `UDashboardToolbar` below the navbar: on the left, a search input
(`UInput` with search icon) that filters contacts as the user types
(debounced). On the right, a type filter using `USelect` with options:
All, Lead, Prospect, Customer, Partner.
- The body shows a table of contacts with columns: name, company, email,
phone, type badge (color-coded — lead = "info", prospect = "warning",
customer = "success", partner = "neutral"), deal count, and owner name.
Use `USkeleton` for loading state on initial load. Show an empty state
with an icon and message when there are no contacts.
- "New Contact" button opens a `UModal` with a form: name (required), email,
phone, company, type (select), tags (comma-separated text input), owner
(select from team members fetched on mount — use placeholder "Unassigned",
do NOT use an empty-string value), notes. Use Zod for validation.
Show loading on submit. Show success toast on creation.
- Clicking a contact row opens a `USlideover` for editing. Show all fields
in a form at the top. Below the form, show two sections:
1. "Deals" — a list of deals linked to this contact (title, stage badge,
value). Clicking a deal navigates to the deals page.
2. "Recent Activity" — the last 5 activities for this contact (type icon,
subject, date). Fetched from
`GET /api/teams/{teamId}/activities?contact_id={contactId}`.
Include a delete button wrapped in
`CanAccess permission="contacts.delete"` with a confirmation modal.
- After creating, editing, or deleting a contact, refresh the contact list.
Sidebar navigation:
Add a "Contacts" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`, between "Dashboard" and
"Settings". Use the icon `i-solar-users-group-two-rounded-bold-duotone`.Deals
Prompt
txt
Build the deals module — this is the core sales pipeline feature.
Server routes (all use `authUser` with the appropriate permission):
- `GET /api/teams/[teamId]/deals` — uses `authUser(event, "deals.view")`.
Returns all deals for the team with the contact name and owner name
joined in. Order by created_at desc. Support optional query params:
`?stage=` to filter by stage, `?owner_id=` to filter by owner UUID.
- `POST /api/teams/[teamId]/deals` — uses `authUser(event, "deals.create")`.
Reads { title, contact_id, value, stage, priority, expected_close_date,
owner_id, notes } from the body. Validates that title and contact_id are
required. Sets team_id from auth context and created_by from the
authenticated user (user.sub).
- `PATCH /api/teams/[teamId]/deals/[dealId]` — uses
`authUser(event, "deals.update")`. Updates whichever fields are provided.
If stage is being changed to "closed_won" or "closed_lost", automatically
set closed_date to today. Validates that the deal belongs to the team.
- `DELETE /api/teams/[teamId]/deals/[dealId]` — uses
`authUser(event, "deals.delete")`. Validates that the deal belongs to
the team before deleting.
- `GET /api/teams/[teamId]/deals/[dealId]/notes` — uses
`authUser(event, "deals.view")`. Returns all notes for the deal with
the author's name joined in, ordered by created_at asc.
- `POST /api/teams/[teamId]/deals/[dealId]/notes` — uses
`authUser(event, "deals.view")`. Any team member can add notes. Reads
{ content } from the body. Sets user_id from the authenticated user.
- `GET /api/teams/[teamId]/activities` — uses
`authUser(event, "activities.view")`. Returns activities for the team,
ordered by activity_date desc. Support optional query params:
`?contact_id=`, `?deal_id=`, `?type=`. Include contact name and deal
title joined in.
- `POST /api/teams/[teamId]/activities` — uses
`authUser(event, "activities.create")`. Reads { contact_id, deal_id,
type, subject, description, activity_date } from the body. Validates
that type and subject are required, and at least one of contact_id or
deal_id must be provided. Sets team_id from auth context and created_by
from the authenticated user.
UI:
Create `app/pages/deals/index.vue` — the main deals page as a top-level
route (not nested under `/dashboard`), wrapped in a `UDashboardPanel`:
- `UDashboardNavbar` in the header: title "Deals",
`UDashboardSidebarCollapse` on the left. On the right, a "New Deal"
button wrapped in `CanAccess permission="deals.create"`.
- `UDashboardToolbar` below the navbar: on the left, show pipeline summary
(e.g. "3 Qualification, 2 Proposal, 1 Negotiation"). On the right,
stage filter tabs using `USelect` with options: All, Qualification,
Proposal, Negotiation, Closed Won, Closed Lost.
- Deal list in the body as a table with columns: title, contact name,
owner name (or "Unassigned"), stage badge (color-coded — qualification =
"info", proposal = "warning", negotiation = "neutral", closed_won =
"success", closed_lost = "error"), priority badge (low = "neutral",
normal = "info", high = "warning"), value (formatted as currency),
and expected close date (formatted nicely).
- Each row has an inline stage dropdown that lets you change the stage
directly from the table. Use `UDropdownMenu` with the stage options.
Show per-row loading state while the stage is being updated.
- "New Deal" button opens a `UModal` with a form: title (required),
contact (required — select from existing contacts fetched on mount),
owner (optional — select from team members fetched on mount, use
placeholder "Unassigned"), stage, priority, value, expected close date,
notes. For the date field, use a `UPopover` with a `UCalendar` inside.
Import `CalendarDate` from `@internationalized/date`. Use Zod for
validation. Show loading on submit.
- Clicking a deal row opens a `USlideover` for the deal detail. Show all
fields in an editable form at the top. Below the form, show two sections:
1. "Notes" — a scrollable list of notes (author name, content, timestamp)
with a text input at the bottom to add a new note.
2. "Activity" — a timeline of activities linked to this deal (type icon,
subject, description, date) with a "Log Activity" button that opens a
small inline form (type select, subject, description).
Include a delete button wrapped in `CanAccess permission="deals.delete"`
with a confirmation modal.
- After creating, editing, or deleting a deal, refresh the deal list.
Sidebar navigation:
Add a "Deals" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`, between "Contacts" and
"Settings". Use the icon `i-solar-dollar-bold-duotone`.Dashboard and Realtime
Prompt 1 — Dashboard
txt
Replace the placeholder dashboard with real CRM data and pipeline stats.
Server route:
Create `GET /api/teams/[teamId]/stats` — uses `authUser(event, "team.view")`.
Returns a JSON object with:
- `contact_count` — total number of contacts for the team
- `open_deals` — count of deals not in "closed_won" or "closed_lost"
- `pipeline_value` — sum of values for open deals. Return 0 if none.
- `won_this_month` — sum of values for deals with stage "closed_won" where
closed_date is in the current calendar month. Return 0 if none.
- `pipeline_by_stage` — array of { stage, count, total_value } for each
active stage (not closed), ordered by the pipeline order
- `recent_activities` — the last 10 activities with contact name, deal
title, type, subject, and activity_date joined in
- `upcoming_closes` — the next 5 deals by expected_close_date that are
still open, with contact name and owner name joined in
All queries are scoped to the team's team_id.
UI:
Update `app/pages/dashboard/index.vue` to show:
- A row of four stat cards at the top using a grid layout. Each card shows
an icon, a label, and the value. Cards: "Total Contacts" (contact_count),
"Open Deals" (open_deals), "Pipeline Value" (pipeline_value formatted as
currency), "Won This Month" (won_this_month formatted as currency).
Use `USkeleton` placeholders while loading.
- Below the stats, a pipeline summary bar: a horizontal segmented bar
showing each stage as a colored segment proportional to its deal count.
Below the bar, show the stage name, deal count, and total value for each
stage. Use the same stage colors as the deal badges.
- Below the pipeline, a two-column layout:
- Left column: "Upcoming Closes" — a list of the next 5 deals expected
to close. Each item shows deal title, contact name, expected close
date, value, and owner avatar or name. Clicking a deal navigates to
the deals page. Show an empty state if no upcoming closes.
- Right column: "Recent Activity" — a list of the last 10 activities.
Each item shows an icon for the activity type, subject, contact/deal
name, and timestamp. Show an empty state if none.
Remove any placeholder/scaffolding content that was in the dashboard before.
Keep the `UDashboardPanel` wrapper with navbar and sidebar collapse.Prompt 2 — Realtime
txt
Add Supabase Realtime sync for the new tables so changes appear instantly
across browser sessions.
Database migration (via Supabase MCP):
Enable realtime publication and full replica identity for the new tables:
```sql
ALTER PUBLICATION supabase_realtime ADD TABLE contacts, deals, activities, deal_notes;
ALTER TABLE contacts REPLICA IDENTITY FULL;
ALTER TABLE deals REPLICA IDENTITY FULL;
ALTER TABLE activities REPLICA IDENTITY FULL;
ALTER TABLE deal_notes REPLICA IDENTITY FULL;
```
Composables:
1. Create `app/composables/useRealtimeContacts.ts` — takes a
`Ref<Contact[]>` (or whatever the contact array type is). Registers on
the `contacts` table via the existing `useRealtime` composable's
`onTable` function. Handles INSERT (dedup against existing items by id),
UPDATE (replace the matching item), and DELETE (remove by id). Always
assign a new array to `.value` — do not mutate in place, or Vue will
not re-render.
2. Create `app/composables/useRealtimeDeals.ts` — same pattern but for the
`deals` table.
Update `app/composables/useRealtime.ts` to subscribe to `contacts`, `deals`,
`activities`, and `deal_notes` tables in addition to the existing
subscriptions.
Integration:
- In `app/pages/contacts/index.vue`, call
`useRealtimeContacts(contacts)` where `contacts` is the ref holding the
contact list.
- In `app/pages/deals/index.vue`, call `useRealtimeDeals(deals)` where
`deals` is the ref holding the deal list.
- The dashboard stats page should refetch stats when any realtime event
fires on contacts, deals, or activities. Use `onTable("contacts",
refetchStats)`, `onTable("deals", refetchStats)`, and
`onTable("activities", refetchStats)` directly in the dashboard page —
no separate composable needed.What You Built
Starting from a template that handled auth, teams, roles, and permissions, you added:
- Contacts — a full CRM contact database with types, tags, and ownership
- Deals — a sales pipeline with stages, values, and close tracking
- Activities — call, email, meeting, and note logging linked to contacts and deals
- Dashboard — pipeline visualization, won revenue, and activity feed
- Realtime — all changes sync instantly across connected browsers
Every feature follows the same patterns: permission-gated server routes, team-scoped data, Nuxt UI components, and the conventions defined in CLAUDE.md.
What's Next
- API Keys & External API — let external services sync contacts or push deals from web forms
- AI Chat — a chatbot that answers questions like "What's our pipeline value?" or "Which deals close this week?"