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.
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,companyinquiry(paragraph), what they’re asking aboutsource(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, defaultnew. Values:new,contacted,qualified,proposal,won,lost,nurtureowner, Hidden, default empty. Will be populated by assignment.follow_up, Date field, default empty. The next-action date.priority, Hidden, defaultnormal. Values:low,normal,high,urgentnotes, 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 changesfilter_user_owns="owner", forsalesrole, the table auto-scopes to rows whereownermatches the logged-in user.sales-managerandadministratorsee everything.allow_edit, the six columns that change daily are editable inline; everything else is read-onlyedit_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 rangebulk="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 tablesort="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:
- Auto-advance status on assignment. When a manager assigns a
newlead, it auto-promotes tocontacted. New is for the unassigned-and-untouched bucket; the moment someone owns it, it’s at least contacted. (Saves a click per lead.) - Required-reason on lost. The
mark_lostaction prompts for a free-text reason and appends it to the notes column with a timestamp. Lost-reason data is gold for sales coaching. - 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>_filter_status=new,contacted,qualified,proposal
/sales/leads?gt_filter_follow_up=overdue
/sales/leads?gt_filter_follow_up=this_week>_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>_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>_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/leadsinstead ofyour-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.
Related#
- Build a moderation queue, same status-machine pattern, public-submission flavor
- Build a customer portal, the customer-facing flip side
- Role-based table permissions, deeper dive on the permissions model
- Add inline editing to Gravity Forms, the editing primitive this guide uses
- Permissions, the three-layer permission model
- Hooks → bulk action registration, full reference for
gt_register_bulk_actions