Sales CRM

A sales CRM, ready to use: Deals, Accounts and Contacts as three linked tables. Link a deal to an account and the account lists it. Views for all deals, the pipeline as a Kanban, the closing calendar, open deals by owner and a new-lead form. (requires @svgrid/enterprise)

A live, editable Svelte 5 data grid example from the SvGrid gallery (Base). See the SvGrid documentation for the full API.

About this example

A sales CRM built with SvGrid Base, a table database component for Svelte 5: three linked tables as tabs. Deals link to an account and a contact, accounts list their deals and contacts and roll up the pipeline value, and linking a record on one side shows it on the other. Saved views for all deals, a Kanban pipeline, a closing calendar, open deals grouped by owner and a new-lead form.

A sales team's CRM as a ready-to-use base with three tables, one tab each. Deals link to an account and a contact; Accounts list their deals and contacts and roll up the pipeline value; Contacts link back to their account and deals. Linking a deal to an account lists it on that account's row, and the other way round.

Add a link field ("+" at the end of the header, "Link to records") to relate two tables: the other table gets a field back. Point it at another table, or delete it, and the field back moves or goes with it. The tabs add, rename, duplicate and delete tables.

Imports, features and API used

Imports: @svgrid/enterprise/base, ../shared/base-people

Columns: deal (Deal), account (Account), contact (Contact), stage (Stage), owner (Owner), value (Value), probability (Probability), close (Close date), nextStep (Next step), source (Source), priority (Priority), stage

Frequently asked questions

Is this a template or a component?

A component: <SvBase> with your rows and columns. Most of the demo is sample data; the views, the field editor, the record view and the form come with the component.

How are deals, accounts and contacts related?

Each is a table of the base, one tab each. The Account field on a deal is a link field; the Accounts table has a Deals field that links back, and the two stay in step: link a deal to an account and the account lists it. Add a link field to relate two tables, point it at another table to move the relation, or delete it to remove both sides.

Does the footer total the pipeline?

Yes. Each field picks its own summary: Value sums, Probability averages. Grouped by owner, every group header shows its own totals.

Which license does it need?

SvGrid Base is part of the Suite edition of @svgrid/enterprise. Without a key it runs with a watermark, so it can be evaluated as is.

Related documentation

Related articles

  • A Svelte Data Grid with SvelteKit and Supabase - Wire SvGrid to a Supabase Postgres backend with server-side pagination, sorting, and filtering - keeping credentials on the server and queries fast with proper indexing.
  • A Live Svelte Data Grid with Firebase Firestore - Wire Firestore onSnapshot to SvGrid in Svelte 5 - reactive listeners, query-driven sort and filter, cursor pagination, and the subtle cleanup detail most implementations get wrong.
  • A Svelte Data Grid with PocketBase - Wire SvGrid to PocketBase for server-side paging, sorting, and filtering via getList, plus live row updates through PocketBase realtime subscriptions.

Source code (512-base-sales-crm.svelte)

<script lang="ts">
  /**
   * 512. SvGrid Base: sales CRM
   * ---------------------------
   * A sales team's CRM as a ready-to-use base with three tables, one tab
   * each. Deals link to an account and a contact; Accounts list their deals
   * and contacts and roll up the pipeline value; Contacts link back to their
   * account and deals. Linking a deal to an account lists it on that
   * account's row, and the other way round.
   *
   * Add a link field ("+" at the end of the header, "Link to records") to
   * relate two tables: the other table gets a field back. Point it at
   * another table, or delete it, and the field back moves or goes with it.
   * The tabs add, rename, duplicate and delete tables.
   */
  import { SvBase, enableBase, type BaseTable } from '@svgrid/enterprise/base'
  import { usePeople } from '../shared/base-people'

  enableBase()
  usePeople()

  const STAGES = [
    { value: 'Lead', color: '#94a3b8' },
    { value: 'Qualified', color: '#18bfff' },
    { value: 'Proposal', color: '#2d7ff9' },
    { value: 'Negotiation', color: '#fcb400' },
    { value: 'Won', color: '#20c933' },
    { value: 'Lost', color: '#f82b60' },
  ]
  const ODDS: Record<string, number> = { Lead: 0.1, Qualified: 0.25, Proposal: 0.5, Negotiation: 0.75, Won: 1, Lost: 0 }
  const SOURCES = [
    { value: 'Inbound', color: '#20c933' },
    { value: 'Referral', color: '#8b46ff' },
    { value: 'Outbound', color: '#ff6f2c' },
    { value: 'Event', color: '#18bfff' },
  ]
  const INDUSTRIES = [
    { value: 'Wholesale', color: '#ff6f2c' },
    { value: 'Manufacturing', color: '#2d7ff9' },
    { value: 'Software', color: '#8b46ff' },
    { value: 'Retail', color: '#fcb400' },
    { value: 'Logistics', color: '#18bfff' },
    { value: 'Healthcare', color: '#20c933' },
    { value: 'Education', color: '#f82b60' },
  ]
  const NEXT = ['Send proposal', 'Book a demo', 'Security review', 'Pricing call', 'Legal redlines', 'Kickoff scheduled', 'Follow up next quarter']
  const PRODUCTS = ['Platform license', 'Team plan', 'Renewal', 'Expansion', 'Pilot', 'Enterprise plan']
  const day = (n: number) => new Date(Date.UTC(2026, 9, 6 + n)).toISOString().slice(0, 10)

  // ---- Sample records -------------------------------------------------------

  const ACCOUNTS = [
    ['Northwind Traders', 'Wholesale', 420, 'Seattle'],
    ['Contoso', 'Manufacturing', 2600, 'Chicago'],
    ['Fabrikam', 'Software', 880, 'Austin'],
    ['Adventure Works', 'Retail', 310, 'Denver'],
    ['Tailspin Toys', 'Retail', 140, 'Portland'],
    ['Wide World Importers', 'Logistics', 1200, 'Rotterdam'],
    ['Litware', 'Healthcare', 560, 'Boston'],
    ['Proseware', 'Education', 95, 'Dublin'],
  ] as const
  const PEOPLE = [
    ['Maya Lindqvist', 'Head of Operations'], ['Tom Becker', 'Procurement lead'],
    ['Priya Raman', 'CFO'], ['Luis Ortega', 'IT director'],
    ['Grace Kim', 'VP Engineering'], ['Owen Price', 'Platform lead'],
    ['Sofia Rossi', 'COO'], ['Ken Watanabe', 'Store systems'],
    ['Nora Ali', 'Founder'], ['Jack Moran', 'Head of Product'],
    ['Eva Novak', 'Supply chain director'], ['Ravi Menon', 'Finance manager'],
    ['Hannah Cole', 'CIO'], ['Marco Bianchi', 'Clinical systems'],
    ['Ines Duarte', 'Head of IT'], ['Leo Hart', 'Dean of Operations'],
  ] as const

  const contacts = PEOPLE.map(([name, title], i) => {
    const acct = Math.floor(i / 2)
    return {
      id: `c${i + 1}`,
      name,
      title,
      account: [`a${acct + 1}`],
      email: `${name.toLowerCase().replace(/[^a-z]+/g, '.')}@${ACCOUNTS[acct]![0].toLowerCase().replace(/[^a-z]+/g, '')}.example.com`,
      phone: `+1 555 01${String(10 + i).padStart(2, '0')}`,
      deals: [] as string[],
    }
  })
  const deals = Array.from({ length: 30 }, (_, i) => {
    const stage = STAGES[[0, 1, 2, 3, 4, 2, 1, 3, 0, 5, 2, 4, 1, 3, 2, 0, 4, 3, 1, 2, 5, 3, 2, 1, 4, 0, 2, 3, 1, 2][i]!]!.value
    const acct = i % ACCOUNTS.length
    return {
      id: `d${i + 1}`,
      // Each account sees each product at most once.
      deal: `${ACCOUNTS[acct]![0]}: ${PRODUCTS[(i - Math.floor(i / ACCOUNTS.length)) % PRODUCTS.length]}`,
      account: [`a${acct + 1}`],
      contact: [`c${acct * 2 + (i % 2) + 1}`],
      stage,
      owner: ['u1', 'u2', 'u4', 'u7'][i % 4]!,
      value: 8000 + ((i * 7919) % 90) * 1500,
      probability: ODDS[stage]!,
      close: day((i * 3) % 50),
      nextStep: stage === 'Won' || stage === 'Lost' ? '' : NEXT[i % NEXT.length]!,
      source: SOURCES[i % SOURCES.length]!.value,
      priority: 1 + ((i * 3) % 5),
    }
  })
  // The fields back list the records that link, as the base keeps them from now on.
  for (const d of deals) contacts.find((c) => c.id === d.contact[0])!.deals.push(d.id)
  const accounts = ACCOUNTS.map(([name, industry, employees, city], i) => ({
    id: `a${i + 1}`,
    name,
    industry,
    employees,
    city,
    deals: deals.filter((d) => d.account[0] === `a${i + 1}`).map((d) => d.id),
    contacts: contacts.filter((c) => c.account[0] === `a${i + 1}`).map((c) => c.id),
  }))

  // ---- The base ---------------------------------------------------------------

  let tables = $state.raw<BaseTable[]>([
    {
      id: 'crm-deals',
      name: 'Deals',
      primaryField: 'deal',
      data: deals,
      columns: [
        { id: 'deal', field: 'deal', header: 'Deal', width: 280, fieldType: 'text' },
        { id: 'account', field: 'account', header: 'Account', width: 180, fieldType: 'link', fieldProps: { table: 'crm-accounts', multiple: false, inverse: 'deals' } },
        { id: 'contact', field: 'contact', header: 'Contact', width: 170, fieldType: 'link', fieldProps: { table: 'crm-contacts', multiple: false, inverse: 'deals' } },
        { id: 'stage', field: 'stage', header: 'Stage', width: 130, fieldType: 'singleSelect', fieldProps: { options: STAGES } },
        { id: 'owner', field: 'owner', header: 'Owner', width: 160, fieldType: 'user' },
        { id: 'value', field: 'value', header: 'Value', width: 130, fieldType: 'number', fieldProps: { format: 'currency', symbol: '$', precision: 0, summary: 'sum' } },
        { id: 'probability', field: 'probability', header: 'Probability', width: 150, fieldType: 'number', fieldProps: { format: 'percent', precision: 0, showBar: true, summary: 'avg' } },
        { id: 'close', field: 'close', header: 'Close date', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },
        { id: 'nextStep', field: 'nextStep', header: 'Next step', width: 200, fieldType: 'text' },
        { id: 'source', field: 'source', header: 'Source', width: 120, fieldType: 'singleSelect', fieldProps: { options: SOURCES } },
        { id: 'priority', field: 'priority', header: 'Priority', width: 120, fieldType: 'rating', fieldProps: { max: 5, icon: 'flag', color: '#f82b60' } },
      ],
      views: [
        { id: 'deals', name: 'All deals', type: 'grid', color: { mode: 'field', field: 'stage' }, state: { sorting: [{ id: 'value', desc: true }] } },
        { id: 'pipeline', name: 'Pipeline', type: 'kanban', stackBy: 'stage' },
        { id: 'closing', name: 'Closing calendar', type: 'calendar', dateField: 'close', color: { mode: 'field', field: 'stage' } },
        { id: 'by-stage', name: 'Pipeline value by stage', type: 'chart', chart: { kind: 'bar', x: 'stage', y: 'value' } },
        {
          id: 'open',
          name: 'Open deals by owner',
          type: 'grid',
          state: {
            grouping: ['owner'],
            sorting: [{ id: 'owner', desc: false }, { id: 'close', desc: false }],
            advancedFilter: { kind: 'fieldFilter', conjunction: 'and', conditions: [{ column: 'stage', kind: 'select', op: 'isNoneOf', value: ['Won', 'Lost'] }] },
          },
        },
        { id: 'lead', name: 'New lead', type: 'form', formFields: ['deal', 'account', 'contact', 'owner', 'value', 'source', 'nextStep'], formDescription: 'A lead from a call, an event or a referral. It starts in the Lead stage.' },
      ],
      props: { rowAdder: { label: 'Add deal', defaults: () => ({ deal: '', stage: 'Lead', probability: 0.1 }) } },
    },
    {
      id: 'crm-accounts',
      name: 'Accounts',
      primaryField: 'name',
      data: accounts,
      columns: [
        { id: 'name', field: 'name', header: 'Account', width: 220, fieldType: 'text' },
        { id: 'industry', field: 'industry', header: 'Industry', width: 150, fieldType: 'singleSelect', fieldProps: { options: INDUSTRIES } },
        { id: 'deals', field: 'deals', header: 'Deals', width: 260, fieldType: 'link', fieldProps: { table: 'crm-deals', multiple: true, inverse: 'account' } },
        { id: 'pipeline', field: 'pipeline', header: 'Pipeline value', width: 170, fieldType: 'rollup', fieldProps: { linkField: 'deals', field: 'value', aggregation: 'SUM' } },
        { id: 'dealCount', field: 'dealCount', header: 'Deal count', width: 120, fieldType: 'count', fieldProps: { linkField: 'deals' } },
        { id: 'contacts', field: 'contacts', header: 'Contacts', width: 220, fieldType: 'link', fieldProps: { table: 'crm-contacts', multiple: true, inverse: 'account' } },
        { id: 'employees', field: 'employees', header: 'Employees', width: 120, fieldType: 'number', fieldProps: { format: 'integer' } },
        { id: 'city', field: 'city', header: 'City', width: 130, fieldType: 'text' },
      ],
      views: [
        { id: 'accounts', name: 'All accounts', type: 'grid', state: { sorting: [{ id: 'name', desc: false }] } },
        { id: 'by-industry', name: 'By industry', type: 'kanban', stackBy: 'industry' },
      ],
      props: { rowAdder: { label: 'Add account' } },
    },
    {
      id: 'crm-contacts',
      name: 'Contacts',
      primaryField: 'name',
      data: contacts,
      columns: [
        { id: 'name', field: 'name', header: 'Name', width: 190, fieldType: 'text' },
        { id: 'title', field: 'title', header: 'Title', width: 190, fieldType: 'text' },
        { id: 'account', field: 'account', header: 'Account', width: 180, fieldType: 'link', fieldProps: { table: 'crm-accounts', multiple: false, inverse: 'contacts' } },
        { id: 'deals', field: 'deals', header: 'Deals', width: 260, fieldType: 'link', fieldProps: { table: 'crm-deals', multiple: true, inverse: 'contact' } },
        { id: 'email', field: 'email', header: 'Email', width: 260, fieldType: 'email' },
        { id: 'phone', field: 'phone', header: 'Phone', width: 150, fieldType: 'phone' },
      ],
      views: [
        { id: 'contacts', name: 'All contacts', type: 'grid' },
        { id: 'add', name: 'Add a contact', type: 'form', formFields: ['name', 'title', 'account', 'email', 'phone'] },
      ],
      props: { rowAdder: { label: 'Add contact' } },
    },
  ])
</script>

<section class="base-demo">
  <SvBase
    bind:tables
    editable
    sortable
    showRowNumbers
    expandRows
    pinSplitter
    summary
    rowNumberWidth={64}
    showRowSelection={false}
    rowHeight={40}
    containerHeight="100%"
    selectionBar={['selectAll', 'editFields', 'deleteRows']}
    columnAdder
    fieldMenu
  />
</section>

<style>
  .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }
</style>

View this example on GitHub

More Base examples

  • Project tracker - A project tracker, ready to use: Tasks and Milestones as two linked tables. Tasks have status, tags, assignee, dates, estimate, progress, budget and files; each milestone lists its tasks with their progress and budget. Views for all tasks, the board by status, due dates on a calendar, a gallery and a request form. Automations close finished tasks, start work from a button and flag overdue ones.
  • Content calendar - An editorial content calendar, ready to use: Posts and Campaigns as two linked tables. Posts have stage, channels, author, publish date, cover and brief. Views for the plan, the pipeline as a Kanban, the publishing calendar, a wall of covers and a pitch form.
  • Product roadmap - A product roadmap, ready to use: Features and customer Feedback as two linked tables. Features have quarter, status, teams, lead, impact, effort and votes. Views for the roadmap grouped by quarter, a status board, a timeline, the work in progress now and a request form.
  • Hiring tracker - A hiring tracker, ready to use: Candidates and Roles as two linked tables. Candidates have photo, role, stage, recruiter, rating, interview date and resume; each role lists its candidates. Views for the pipeline as a Kanban, all candidates, a gallery of faces, interviews and an application form.
  • Inventory - An inventory, ready to use: Items and Suppliers as two linked tables. Items have photos, category, location, quantity, unit cost and a stock value formula; each supplier lists its items and units in stock. Views for stock by category, photos, items by location, a reorder list and an add-item form.