Skip to content
Gravity Tables
tutorial

Build a CRM lead dashboard with Gravity Forms and Gravity Tables

Turn the leads landing in Gravity Forms into a real, tabular CRM. Status pipeline, owner assignment, follow-up dates, inline editing, and role-aware exports, without a separate CRM subscription.

· 9 min read · By Fahd Murtaza

Most small teams collect leads through a Gravity Form and then watch them rot in the entries screen. A real CRM costs $30-$80 per seat per month, takes a week to set up, and ends up out of sync with the form anyway because copy/paste is involved.

This guide ships the practical CRM your sales team actually needs, pipeline status, owner assignment, follow-up date, notes, exports, in about an hour, on the WordPress install you already pay for, with one Gravity Form and one Gravity Tables shortcode.

What you’ll build#

A /sales/leads page that gives your team:

  • A live, sortable, filterable table of every lead the form has captured
  • Inline editing on the columns that change every day (status, owner, follow_up, notes)
  • Read-only on the columns that shouldn’t change (the lead’s own contact info)
  • Filter chips to switch between “my leads”, “untouched > 7 days”, “this week’s pipeline”
  • Bulk actions to assign owners, mark won/lost, export the highlighted set
  • An audit log so the manager can answer “who changed this status, and when”

A new lead lands as status: new, owner: unassigned. The team picks it up, runs it through the pipeline, and the table is the single source of truth.

Step 1: shape the form#

Add (or confirm) these fields on your existing lead-capture form. The customer-facing form only collects the contact info; the rest are admin-only fields the table will edit.

Customer-visible fields (whatever you already collect):

  • first_name, last_name, email, phone, company
  • inquiry (paragraph), what they’re asking about
  • source (hidden, populated from UTM or referrer), how they found you

Admin-only fields to add (each set to “Hidden” with the listed default):

  • status, Hidden, default new. Values: new, contacted, qualified, proposal, won, lost, nurture
  • owner, Hidden, default empty. Will be populated by assignment.
  • follow_up, Date field, default empty. The next-action date.
  • priority, Hidden, default normal. Values: low, normal, high, urgent
  • notes, Paragraph, default empty. Internal commentary.
  • value, Number, default empty. Estimated deal value (for the totals row).

Step 2: build the dashboard page#

Create a page at /sales/leads (or wherever your team lives) and drop in this shortcode:

[gravity_table id="lead-form"
  columns="created,first_name,last_name,company,email,phone,status,owner,follow_up,priority,value,notes"
  filter_user_owns="owner"
  allowed_roles="sales,sales-manager,administrator"
  allow_edit="status,owner,follow_up,priority,value,notes"
  edit_permissions="owner:sales-manager,value:sales-manager"
  filters="status,owner,priority,follow_up"
  bulk="assign,mark_won,mark_lost,export,delete"
  bulk_permissions="delete:manage_options"
  totals="value"
  sort="follow_up:asc,created:desc"
  audit_log="true"
  auto_refresh="true"
  refresh_interval="30"
  per_page="50"]

Eight knobs are doing real work here. Each one earns its place:

  • columns, explicit column order, no surprises when the form changes
  • filter_user_owns="owner", for sales role, the table auto-scopes to rows where owner matches the logged-in user. sales-manager and administrator see everything.
  • allow_edit, the six columns that change daily are editable inline; everything else is read-only
  • edit_permissions="owner:sales-manager,value:sales-manager", only the manager can reassign a lead or change the deal value. Reps can update status / follow-up / notes / priority on their own.
  • filters, drop-down chips above the table for status, owner, priority, follow-up date range
  • bulk="assign,mark_won,mark_lost,export,delete", manager-grade bulk actions. Delete is admin-only.
  • totals="value", pipeline value sum, recalculates as filters narrow the table
  • sort="follow_up:asc,created:desc", overdue follow-ups float to the top; ties broken by newest

Step 3: register the custom bulk actions#

The default bulk parameter only knows generic actions. To make assign, mark_won, and mark_lost actually do their jobs, register them via the gt_register_bulk_actions filter in your child theme’s functions.php:

add_filter('gt_register_bulk_actions', function ($actions) {
    $actions['assign'] = [
        'label' => 'Assign to user…',
        'capability' => 'edit_others_posts',
        'prompt' => [
            'type' => 'user_select',
            'roles' => ['sales', 'sales-manager'],
            'label' => 'Assign selected leads to:',
        ],
        'callback' => function ($entry_ids, $args) {
            $owner = sanitize_text_field($args['user']);
            $count = 0;
            foreach ($entry_ids as $id) {
                GFAPI::update_entry_field($id, 'owner', $owner);
                if (gform_get_meta($id, 'status') === 'new') {
                    GFAPI::update_entry_field($id, 'status', 'contacted');
                }
                $count++;
            }
            return ['success' => $count, 'message' => "Assigned {$count} leads to {$owner}"];
        },
    ];

    $actions['mark_won'] = [
        'label' => 'Mark as won',
        'capability' => 'edit_others_posts',
        'callback' => function ($entry_ids) {
            foreach ($entry_ids as $id) {
                GFAPI::update_entry_field($id, 'status', 'won');
            }
            return ['success' => count($entry_ids), 'message' => count($entry_ids) . ' leads marked won'];
        },
    ];

    $actions['mark_lost'] = [
        'label' => 'Mark as lost',
        'capability' => 'edit_others_posts',
        'prompt' => [
            'type' => 'textarea',
            'label' => 'Reason for losing (optional, recorded in notes):',
        ],
        'callback' => function ($entry_ids, $args) {
            $reason = sanitize_textarea_field($args['textarea'] ?? '');
            foreach ($entry_ids as $id) {
                GFAPI::update_entry_field($id, 'status', 'lost');
                if ($reason) {
                    $existing = gform_get_meta($id, 'notes');
                    $stamp = wp_date('Y-m-d H:i') . ', Lost: ' . $reason;
                    GFAPI::update_entry_field($id, 'notes', trim($existing . "\n" . $stamp));
                }
            }
            return ['success' => count($entry_ids), 'message' => count($entry_ids) . ' leads marked lost'];
        },
    ];

    return $actions;
});

Three patterns worth noticing in that block:

  1. Auto-advance status on assignment. When a manager assigns a new lead, it auto-promotes to contacted. New is for the unassigned-and-untouched bucket; the moment someone owns it, it’s at least contacted. (Saves a click per lead.)
  2. Required-reason on lost. The mark_lost action prompts for a free-text reason and appends it to the notes column with a timestamp. Lost-reason data is gold for sales coaching.
  3. Capability check, not role check. All three actions check edit_others_posts (a capability), not a role name. This means custom-role plugins still work without rewriting the actions.

Step 4: stand up saved filter views#

The filters shortcode parameter exposes drop-down chips for every column listed. To go further and offer named saved views, “My open leads”, “Stalled (no touch in 7 days)”, “This week’s follow-ups”, link directly to pre-filtered URLs:

/sales/leads?gt_filter_owner=current_user&gt_filter_status=new,contacted,qualified,proposal
/sales/leads?gt_filter_follow_up=overdue
/sales/leads?gt_filter_follow_up=this_week&gt_filter_status=qualified,proposal

Wrap those in a small navigation strip above the shortcode (in the page editor or a block):

<nav class="lead-views" aria-label="Lead views">
  <a href="/sales/leads">All</a>
  <a href="/sales/leads?gt_filter_owner=current_user&gt_filter_status=new,contacted,qualified,proposal">My open</a>
  <a href="/sales/leads?gt_filter_follow_up=overdue">Overdue</a>
  <a href="/sales/leads?gt_filter_follow_up=this_week">This week</a>
  <a href="/sales/leads?gt_filter_status=won">Won</a>
  <a href="/sales/leads?gt_filter_status=lost">Lost</a>
</nav>

Gravity Tables reads gt_filter_* query parameters as initial filter state, so the table loads pre-narrowed without any JavaScript glue.

Step 5: wire stale-lead nudges#

A lead untouched for 7 days is the leakiest part of any pipeline. A daily WP-Cron task that DMs the lead’s owner is two things: cheap to write, and high-leverage.

add_action('init', function () {
    if (!wp_next_scheduled('gt_lead_stale_nudge')) {
        wp_schedule_event(time(), 'daily', 'gt_lead_stale_nudge');
    }
});

add_action('gt_lead_stale_nudge', function () {
    $cutoff = wp_date('Y-m-d', strtotime('-7 days'));
    $leads = GFAPI::get_entries('lead-form', [
        'field_filters' => [
            ['key' => 'status', 'value' => ['new','contacted','qualified','proposal'], 'operator' => 'in'],
            ['key' => 'follow_up', 'value' => $cutoff, 'operator' => '<'],
        ],
    ]);

    $by_owner = [];
    foreach ($leads as $lead) {
        $owner = $lead['owner'] ?: 'unassigned';
        $by_owner[$owner][] = $lead;
    }

    foreach ($by_owner as $owner => $owner_leads) {
        if ($owner === 'unassigned') continue;
        $user = get_user_by('login', $owner);
        if (!$user) continue;
        $count = count($owner_leads);
        wp_mail(
            $user->user_email,
            "{$count} leads need a follow-up",
            "You have {$count} leads with a follow-up date in the past:\n\n" .
            "https://your-site.com/sales/leads?gt_filter_owner=current_user&gt_filter_follow_up=overdue"
        );
    }
});

One email per rep per day, only when there’s something to nudge about. No noise.

Step 6: dashboard widgets for the manager#

The manager’s view is different from the rep’s, they want pipeline aggregates, not row-level edits. Drop a separate, read-only gravity_table shortcode on /sales/manager with grouped totals:

[gravity_table id="lead-form"
  columns="status,owner,_count,_sum_value"
  group_by="status,owner"
  totals="_sum_value"
  filters="created,priority"
  allowed_roles="sales-manager,administrator"
  allow_edit=""
  per_page="100"]

group_by="status,owner" rolls the table up by pipeline stage and rep, with a count and sum-of-value column. This is the “pipeline by rep, by stage” report every sales manager wants and almost no CRM produces in under a minute.

Step 7: exports for the leadership review#

Once a week, leadership wants the pipeline as a spreadsheet. Three options:

Option A: ad-hoc export from the table#

The table’s export button is already wired; manager picks Excel, the file downloads. Done.

Option B: scheduled email export#

Add a weekly cron that runs the same export and emails it:

add_action('gt_weekly_pipeline_export', function () {
    $file = gt_export_table('lead-form', [
        'format' => 'xlsx',
        'columns' => ['created','first_name','last_name','company','status','owner','value','follow_up'],
        'filename' => 'pipeline-' . wp_date('Y-W') . '.xlsx',
    ]);
    wp_mail('leadership@your-co.com', 'Weekly pipeline', 'Attached.', [], [$file]);
});

if (!wp_next_scheduled('gt_weekly_pipeline_export')) {
    wp_schedule_event(strtotime('next monday 8am'), 'weekly', 'gt_weekly_pipeline_export');
}

Option C: a public-but-tokenized read-only URL#

For investors or partners who need a live view without a WordPress login: generate a signed URL that grants read-only access to a single grouped view for 30 days. Use the gt_signed_view helper (covered in Permissions).

What this gives you that a real CRM doesn’t#

This setup will not replace Salesforce for a 200-rep enterprise. For a 1-10 person team, what it does give you:

  • The form and the CRM are the same data. No sync delay, no Zapier-broken-yesterday, no dual data entry.
  • No per-seat pricing. Add reps as WordPress users. Done.
  • Lives in the site you already pay for. Same login, same hosting, same backup.
  • Branded URL, no tab-switching. your-site.com/sales/leads instead of your-co.lightning.force.com/....
  • Real exports. Excel files with every column and every active filter, not screenshot paste-ups.

What it doesn’t give you:

  • Multi-pipeline support (one form = one pipeline)
  • Built-in email sequencing (use Brevo, Mailchimp, or FluentCRM as the email layer)
  • Mobile-app push notifications (rep gets the daily email, opens the URL on phone)
  • Forecasting models / weighted-stage analytics (export to your spreadsheet of choice)

For most teams between “we use a spreadsheet” and “we have a Salesforce admin”, this is the right tool.

Recipe variations#

B2B SaaS lead pipeline#

Add a mrr_estimate field, group by status and lead_source, set the totals on mrr_estimate. The dashboard tells you which acquisition channel is producing the most pipeline value.

Real-estate agent leads#

Add property_type, budget_range, area. Filter views: “Buyers under $500k”, “Sellers in 02446”, “Cold > 14 days”.

Agency project intake#

Drop the value field, add project_type, timeline, budget_band. Group by project_type to see which service line is filling the inbox.

Don’ts#

Don’t expose status on the public submission form#

It’s a workflow primitive. A submitter setting status: qualified to skip qualification breaks the whole pipeline. Keep it Hidden.

Don’t use the same form for unrelated lead types#

Two forms for “demo request” vs “support inquiry” share a CRM-style table only if they share columns. If they don’t, ship two tables. Filtering one form by lead-type is fine; cramming three different schemas into one form is not.

Don’t skip audit_log#

Sales arguments are reliably about who changed what. The audit log is your defense.