Skip to content

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:

  1. Contacts — a full CRM contact database with types, tags, and ownership
  2. Deals — a sales pipeline with stages, values, and close tracking
  3. Activities — call, email, meeting, and note logging linked to contacts and deals
  4. Dashboard — pipeline visualization, won revenue, and activity feed
  5. 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?"