Skip to contentVibraUI
Dashboards

CRM actions

The CRM dashboard's shared server actions: converting a lead into a qualified deal, imported by every CRM page that offers it.

A "use server" module that exports async functions and types only, installed under the consumer's lib alias beside the dashboard's nav, where the shadcn CLI rewrites a page's @/registry/vibra/lib/dashboards/crm/actions import to. The overview's leads table and the leads page both convert a lead, so the conversion lives here once: every page that imports it lists actions-crm in its registry dependencies, and so the crm dashboard item, whose registry dependencies are the union of its pages', installs it with the rest. The action trusts the rows it looks up, never the page that asked.

Install

npx shadcn@latest add @vibra/actions-crm

Needs the @vibra registry in your components.json — set it up once.

Props

PropTypeDefaultDescription
convertLead(id: string) => Promise<Result<ConvertedLead>>—Marks the lead won and opens a qualified deal under its company for its value, owned by its rep, forecast a month out; refuses a lead already won or lost (conflict) and an unknown id (not_found).
ConvertedLead{ id; name; status; dealId; dealName; amountCents; ownerName }—What a conversion answers: the lead, its new status, the deal that opened and who owns it.

Dependencies

Source

lib/dashboards/crm/actions.ts
"use server"

import { daysAhead, db, REFERENCE_DATE, type Deal, type Lead, type Result } from "@/lib/sample-data"

/**
 * The CRM dashboard's shared server actions: what more than one of its pages
 * does to the same rows. The overview's leads table and the leads page both
 * convert a lead, so the conversion lives here, once, and both import it —
 * one action name, one behaviour, everywhere the dashboard offers it.
 *
 * Installed as `@lib/dashboards/crm/actions.ts` by the `actions-crm` item,
 * which every page that imports it lists in its registry dependencies, so
 * installing the dashboard installs it too. Every answer is a `Result`, and
 * every name in it is read off the rows the action looked up, never off the
 * page that asked.
 */

/** Where a deal converted from a lead says it came from. */
const DEAL_SOURCE: Record<Lead["source"], Deal["source"]> = {
  website: "inbound",
  ads: "inbound",
  referral: "referral",
  social: "outbound",
  event: "event",
}

/** Midnight UTC on the day `date` falls on — a forecast names a day, not an instant. */
function utcDay(date: Date): Date {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}

export type ConvertedLead = {
  id: string
  name: string
  /** The lead's status after the conversion: won. */
  status: Lead["status"]
  dealId: string
  dealName: string
  /** The deal's value, in minor units — the lead's own. */
  amountCents: number
  /** The rep who owns the deal — the lead's own. */
  ownerName: string
}

/**
 * Turns a lead into a deal on the pipeline. The lead is marked won — it has
 * done its job — and a qualified deal opens under its company, for its value,
 * owned by its rep, forecast a month out. The lead is written first: a deal
 * that failed to open leaves a won lead with no deal, which is visible, where
 * the other order would leave a deal on the pipeline for a lead still open,
 * which is not. A lead that is already won or lost is refused: there is
 * nothing left to convert.
 */
export async function convertLead(id: string): Promise<Result<ConvertedLead>> {
  const lead = await db.leads.get(id)
  if (!lead) return { ok: false, error: { code: "not_found", message: `No lead with id "${id}".` } }
  if (lead.status === "won" || lead.status === "lost") {
    return { ok: false, error: { code: "conflict", message: `${lead.name} is already ${lead.status}.` } }
  }

  const updated = await db.leads.update(id, { status: "won", nextActionAt: null })
  if (!updated.ok) return updated

  const created = await db.deals.create({
    name: `${lead.company} · New business`,
    company: lead.company,
    stage: "qualified",
    amountCents: lead.valueCents,
    currency: "USD",
    owner: lead.ownerId,
    // A qualified deal, as the pipeline forecasts one.
    probability: 30,
    source: DEAL_SOURCE[lead.source],
    createdAt: REFERENCE_DATE,
    lastActivityAt: REFERENCE_DATE,
    expectedCloseAt: utcDay(daysAhead(30)),
  })
  if (!created.ok) return created

  const owner = await db.members.get(lead.ownerId)
  return {
    ok: true,
    data: {
      id,
      name: lead.name,
      status: updated.data.status,
      dealId: created.data.id,
      dealName: created.data.name,
      amountCents: created.data.amountCents,
      ownerName: owner?.name ?? "its rep",
    },
  }
}