diff --git a/app/app/leads/[leadId]/page.tsx b/app/app/leads/[leadId]/page.tsx
index 78cc361..0c8de6f 100644
--- a/app/app/leads/[leadId]/page.tsx
+++ b/app/app/leads/[leadId]/page.tsx
@@ -8,6 +8,7 @@ import { Button, buttonVariants } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { requireBusiness } from '@/lib/auth';
import { getLeadDetailForBusiness } from '@/lib/business-access';
+import { isLeadClosedWonStatus, isLeadLostStatus } from '@/lib/lead-outcomes';
import {
formatDateTime,
formatRelativeTime,
@@ -32,11 +33,11 @@ function resolveSafeReturnPath(value: string | null | undefined) {
}
function getLeadActionSummary(status: LeadStatus) {
- if (status === LeadStatus.BOOKED) {
- return 'This lead is marked booked. Keep the details here for reference.';
+ if (isLeadClosedWonStatus(status)) {
+ return 'This lead is marked Closed (Won). Keep the details here for reference.';
}
- if (status === LeadStatus.LOST) {
+ if (isLeadLostStatus(status)) {
return 'This lead is marked lost. You can still review the call and message history here.';
}
@@ -44,7 +45,7 @@ function getLeadActionSummary(status: LeadStatus) {
return 'You have already made contact. Update the final outcome when the customer decides.';
}
- return 'Call this lead back, then update the outcome so your inbox stays clear.';
+ return 'Call this lead back, then mark it Closed (Won) or Lost so your conversion summary stays accurate.';
}
function LeadStatusActionForm({
@@ -137,6 +138,11 @@ export default async function LeadDetailPage({
{lead.notifiedAt || lead.ownerNotifiedAt ? Owner alerted : null}
{getLeadActionSummary(lead.status)}
+ {isOpenLead ? (
+
+ Did this lead turn into a real job? Mark the outcome here after the call so CallbackCloser can show the value clearly.
+
+ ) : null}
@@ -144,8 +150,8 @@ export default async function LeadDetailPage({
Call now
-
-
+
+
diff --git a/app/app/leads/actions.ts b/app/app/leads/actions.ts
index bb43068..5c22bed 100644
--- a/app/app/leads/actions.ts
+++ b/app/app/leads/actions.ts
@@ -35,6 +35,7 @@ export async function updateLeadStatusAction(formData: FormData) {
}
revalidatePath('/app/leads');
+ revalidatePath('/app');
revalidatePath('/app/conversations');
revalidatePath(`/app/leads/${lead.id}`);
redirect(`${redirectTo}${redirectTo.includes('?') ? '&' : '?'}saved=1`);
diff --git a/app/app/leads/page.tsx b/app/app/leads/page.tsx
index 2c94337..fe9c624 100644
--- a/app/app/leads/page.tsx
+++ b/app/app/leads/page.tsx
@@ -2,11 +2,13 @@ import Link from 'next/link';
import { LeadReadiness, LeadStatus } from '@prisma/client';
import { CustomerLeadRow } from '@/components/customer-lead-row';
+import { LeadConversionSummaryCard } from '@/components/lead-conversion-summary-card';
import { Badge } from '@/components/ui/badge';
import { buttonVariants } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { requireBusiness } from '@/lib/auth';
import { listAllDashboardLeadsForBusiness } from '@/lib/business-access';
+import { getLeadOutcomeSummary } from '@/lib/lead-outcomes';
import { getLeadLastActivityAt, isLeadOpenStatus, leadStatusLabels } from '@/lib/lead-presenters';
import { getPortfolioDemoLeads, isPortfolioDemoMode } from '@/lib/portfolio-demo';
import { cn } from '@/lib/utils';
@@ -110,6 +112,7 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc
const view = statusFilter ? 'all' : parseInboxView(typeof searchParams?.view === 'string' ? searchParams.view : undefined);
const allLeads = demoMode ? getPortfolioDemoLeads(null) : await listAllDashboardLeadsForBusiness(business.id);
const hasLeads = allLeads.length > 0;
+ const outcomeSummary = getLeadOutcomeSummary(allLeads);
const filteredLeads = statusFilter
? allLeads.filter((lead) => lead.status === statusFilter)
@@ -138,7 +141,7 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc
},
{
key: 'booked' as const,
- label: 'Booked',
+ label: 'Closed',
href: buildLeadsHref('booked'),
count: allLeads.filter((lead) => lead.status === LeadStatus.BOOKED).length,
active: !statusFilter && view === 'booked',
@@ -155,8 +158,8 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc
const listDescription = statusFilter
? `Showing ${leadStatusLabels[statusFilter].toLowerCase()} leads. Open a lead to act on it.`
: view === 'attention'
- ? 'Showing leads that still need action. Open one to call back or update the outcome.'
- : 'Open any lead to review the conversation and update the outcome from the detail page.';
+ ? 'Showing leads that still need action. Open one to call back or mark the final outcome.'
+ : 'Open any lead to review the conversation and mark it closed or lost from the detail page.';
return (
@@ -173,6 +176,11 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Searc
{error ?
{error}
: null}
{saved ?
Lead updated.
: null}
+
+
{!hasLeads ? (
diff --git a/app/app/page.tsx b/app/app/page.tsx
index 65946ab..224b297 100644
--- a/app/app/page.tsx
+++ b/app/app/page.tsx
@@ -2,12 +2,14 @@ import Link from 'next/link';
import { LeadReadiness, LeadStatus } from '@prisma/client';
import { CustomerLeadRow } from '@/components/customer-lead-row';
+import { LeadConversionSummaryCard } from '@/components/lead-conversion-summary-card';
import { Badge } from '@/components/ui/badge';
import { buttonVariants } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { requireBusiness } from '@/lib/auth';
import { listAllDashboardLeadsForBusiness } from '@/lib/business-access';
import { db } from '@/lib/db';
+import { getLeadOutcomeSummary } from '@/lib/lead-outcomes';
import { getLeadLastActivityAt, isLeadOpenStatus } from '@/lib/lead-presenters';
import { getPortfolioDemoBusiness, getPortfolioDemoLeads, isPortfolioDemoMode } from '@/lib/portfolio-demo';
import { getCustomerSystemStatus } from '@/lib/system-status';
@@ -16,60 +18,6 @@ function buildLeadDetailHref(leadId: string) {
return `/app/leads/${leadId}?from=%2Fapp`;
}
-function parseTimeZoneOffsetMinutes(value: string) {
- const match = value.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
- if (!match) return 0;
-
- const sign = match[1] === '+' ? 1 : -1;
- const hours = Number(match[2]);
- const minutes = Number(match[3] || '0');
- return sign * (hours * 60 + minutes);
-}
-
-function getTimeZoneOffsetMinutes(date: Date, timeZone: string) {
- const parts = new Intl.DateTimeFormat('en-US', {
- timeZone,
- timeZoneName: 'shortOffset',
- hour: '2-digit',
- minute: '2-digit',
- }).formatToParts(date);
-
- const label = parts.find((part) => part.type === 'timeZoneName')?.value || 'GMT';
- return parseTimeZoneOffsetMinutes(label);
-}
-
-function getTodayRangeForTimeZone(timeZone: string) {
- try {
- const now = new Date();
- const parts = new Intl.DateTimeFormat('en-US', {
- timeZone,
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- }).formatToParts(now);
-
- const year = Number(parts.find((part) => part.type === 'year')?.value || now.getUTCFullYear());
- const month = Number(parts.find((part) => part.type === 'month')?.value || now.getUTCMonth() + 1);
- const day = Number(parts.find((part) => part.type === 'day')?.value || now.getUTCDate());
-
- const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
- const endGuess = new Date(Date.UTC(year, month - 1, day + 1, 0, 0, 0));
-
- return {
- start: new Date(startGuess.getTime() - getTimeZoneOffsetMinutes(startGuess, timeZone) * 60_000),
- end: new Date(endGuess.getTime() - getTimeZoneOffsetMinutes(endGuess, timeZone) * 60_000),
- };
- } catch {
- const start = new Date();
- start.setHours(0, 0, 0, 0);
-
- const end = new Date(start);
- end.setDate(end.getDate() + 1);
-
- return { start, end };
- }
-}
-
function getAttentionPriority(lead: {
status: LeadStatus;
readiness: LeadReadiness;
@@ -102,30 +50,19 @@ function getAttentionPriority(lead: {
export default async function AppHomePage() {
const demoMode = isPortfolioDemoMode();
const business = demoMode ? getPortfolioDemoBusiness() : await requireBusiness();
- const { start, end } = getTodayRangeForTimeZone(business.timezone || 'America/New_York');
- const [allLeads, notificationSettings, missedCallsToday] = demoMode
+ const [allLeads, notificationSettings] = demoMode
? [
getPortfolioDemoLeads(null),
null,
- getPortfolioDemoLeads(null).filter((lead) => lead.createdAt >= start && lead.createdAt < end).length,
]
: await Promise.all([
listAllDashboardLeadsForBusiness(business.id),
db.businessNotificationSettings.findUnique({ where: { businessId: business.id } }),
- db.call.count({
- where: {
- businessId: business.id,
- missed: true,
- createdAt: {
- gte: start,
- lt: end,
- },
- },
- }),
]);
const successfulLeadCount = allLeads.filter((lead) => lead.ownerNotifiedAt || lead.notifiedAt).length;
+ const outcomeSummary = getLeadOutcomeSummary(allLeads);
const systemStatus = getCustomerSystemStatus(business, successfulLeadCount);
const attentionLeads = allLeads
.filter((lead) => isLeadOpenStatus(lead.status))
@@ -155,29 +92,6 @@ export default async function AppHomePage() {
(notificationSettings?.notifyEmail && notificationSettings.ownerEmail),
);
- const summaryCards = [
- {
- label: 'New leads',
- value: allLeads.filter((lead) => lead.status === LeadStatus.NEW).length,
- href: '/app/leads?status=new',
- },
- {
- label: 'Needs follow-up',
- value: allLeads.filter((lead) => isLeadOpenStatus(lead.status)).length,
- href: '/app/leads?view=attention',
- },
- {
- label: 'Booked',
- value: allLeads.filter((lead) => lead.status === LeadStatus.BOOKED).length,
- href: '/app/leads?status=booked',
- },
- {
- label: 'Missed calls today',
- value: missedCallsToday,
- href: '/app/leads?view=attention',
- },
- ];
-
const healthItems = [
{
label: 'Texting',
@@ -213,14 +127,10 @@ export default async function AppHomePage() {
-
- {summaryCards.map((card) => (
-
- {card.label}
- {card.value}
-
- ))}
-
+
diff --git a/components/lead-conversion-summary-card.tsx b/components/lead-conversion-summary-card.tsx
new file mode 100644
index 0000000..2604b72
--- /dev/null
+++ b/components/lead-conversion-summary-card.tsx
@@ -0,0 +1,66 @@
+import Link from 'next/link';
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { formatConversionRate, type LeadOutcomeSummary } from '@/lib/lead-outcomes';
+import { cn } from '@/lib/utils';
+
+export function LeadConversionSummaryCard({
+ summary,
+ title = 'Conversion summary',
+ description = 'Only the few numbers that show whether missed calls are turning into real jobs.',
+ className,
+}: {
+ summary: LeadOutcomeSummary;
+ title?: string;
+ description?: string;
+ className?: string;
+}) {
+ const metrics = [
+ {
+ label: 'Leads',
+ value: String(summary.totalLeads),
+ href: '/app/leads',
+ },
+ {
+ label: 'Closed',
+ value: String(summary.closedLeads),
+ href: '/app/leads?status=booked',
+ },
+ {
+ label: 'Lost',
+ value: String(summary.lostLeads),
+ href: '/app/leads?status=lost',
+ },
+ {
+ label: 'Conversion rate',
+ value: formatConversionRate(summary.conversionRate),
+ },
+ ];
+
+ return (
+
+
+ {title}
+ {description}
+
+
+
+ {metrics.map((metric) =>
+ metric.href ? (
+
+
{metric.label}
+
{metric.value}
+
+ ) : (
+
+
{metric.label}
+
{metric.value}
+
+ ),
+ )}
+
+ Conversion rate = closed leads divided by total missed-call leads.
+
+
+ );
+}
diff --git a/lib/lead-outcomes.ts b/lib/lead-outcomes.ts
new file mode 100644
index 0000000..5c5aaae
--- /dev/null
+++ b/lib/lead-outcomes.ts
@@ -0,0 +1,52 @@
+import { LeadStatus } from '@prisma/client';
+
+export type LeadOutcomeSummary = {
+ totalLeads: number;
+ closedLeads: number;
+ lostLeads: number;
+ openLeads: number;
+ conversionRate: number;
+};
+
+type LeadOutcomeInput = {
+ status: LeadStatus;
+};
+
+export function isLeadClosedWonStatus(status: LeadStatus) {
+ return status === LeadStatus.BOOKED;
+}
+
+export function isLeadLostStatus(status: LeadStatus) {
+ return status === LeadStatus.LOST;
+}
+
+export function getLeadOutcomeSummary(leads: LeadOutcomeInput[]): LeadOutcomeSummary {
+ const summary = leads.reduce(
+ (result, lead) => {
+ if (isLeadClosedWonStatus(lead.status)) {
+ result.closedLeads += 1;
+ } else if (isLeadLostStatus(lead.status)) {
+ result.lostLeads += 1;
+ } else {
+ result.openLeads += 1;
+ }
+
+ return result;
+ },
+ {
+ totalLeads: leads.length,
+ closedLeads: 0,
+ lostLeads: 0,
+ openLeads: 0,
+ conversionRate: 0,
+ },
+ );
+
+ summary.conversionRate = summary.totalLeads === 0 ? 0 : Math.round((summary.closedLeads / summary.totalLeads) * 100);
+
+ return summary;
+}
+
+export function formatConversionRate(value: number) {
+ return `${value}%`;
+}
diff --git a/lib/lead-presenters.ts b/lib/lead-presenters.ts
index 3fa773b..76d4f97 100644
--- a/lib/lead-presenters.ts
+++ b/lib/lead-presenters.ts
@@ -1,5 +1,7 @@
import { LeadReadiness, LeadStatus, SmsConversationState, type Lead } from '@prisma/client';
+import { isLeadClosedWonStatus, isLeadLostStatus } from '@/lib/lead-outcomes';
+
export const leadStatusOrder: LeadStatus[] = [
LeadStatus.NEW,
LeadStatus.QUALIFIED,
@@ -14,7 +16,7 @@ export const leadStatusLabels: Record = {
QUALIFIED: 'Qualified',
NOTIFIED: 'Notified',
CONTACTED: 'Contacted',
- BOOKED: 'Booked',
+ BOOKED: 'Closed (Won)',
LOST: 'Lost',
};
@@ -81,20 +83,20 @@ export function formatRelativeTime(value: Date | null | undefined, now: Date = n
}
export function getLeadStatusBadgeVariant(status: LeadStatus) {
- if (status === LeadStatus.BOOKED) return 'success';
- if (status === LeadStatus.LOST) return 'destructive';
+ if (isLeadClosedWonStatus(status)) return 'success';
+ if (isLeadLostStatus(status)) return 'destructive';
if (status === LeadStatus.NEW) return 'outline';
if (status === LeadStatus.NOTIFIED) return 'success';
return 'secondary';
}
export function isLeadOpenStatus(status: LeadStatus) {
- return status !== LeadStatus.BOOKED && status !== LeadStatus.LOST;
+ return !isLeadClosedWonStatus(status) && !isLeadLostStatus(status);
}
export function getLeadNextStepLabel(status: LeadStatus) {
- if (status === LeadStatus.BOOKED) return 'Booked';
- if (status === LeadStatus.LOST) return 'Closed lost';
+ if (isLeadClosedWonStatus(status)) return 'Closed won';
+ if (isLeadLostStatus(status)) return 'Closed lost';
if (status === LeadStatus.CONTACTED) return 'Follow up again';
return 'Needs follow-up';
}
@@ -108,8 +110,8 @@ export function getLeadLastActivityAt(lead: LeadActivityInput) {
type LeadCallbackStateInput = Pick;
export function getLeadCallbackState(lead: LeadCallbackStateInput) {
- if (lead.status === LeadStatus.BOOKED) return 'Booked';
- if (lead.status === LeadStatus.LOST) return 'Lost';
+ if (isLeadClosedWonStatus(lead.status)) return 'Closed won';
+ if (isLeadLostStatus(lead.status)) return 'Lost';
if (lead.status === LeadStatus.CONTACTED) return 'Contacted';
if (lead.billingRequired) return 'Billing paused';
if (lead.status === LeadStatus.NOTIFIED || lead.notifiedAt || lead.ownerNotifiedAt || lead.smsState === SmsConversationState.COMPLETED) {
diff --git a/tests/lead-outcomes.test.ts b/tests/lead-outcomes.test.ts
new file mode 100644
index 0000000..4fde124
--- /dev/null
+++ b/tests/lead-outcomes.test.ts
@@ -0,0 +1,35 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { LeadStatus } from '@prisma/client';
+
+import { formatConversionRate, getLeadOutcomeSummary } from '../lib/lead-outcomes.ts';
+
+test('lead outcome summary counts closed and lost leads with a simple conversion rate', () => {
+ const summary = getLeadOutcomeSummary([
+ { status: LeadStatus.NEW },
+ { status: LeadStatus.CONTACTED },
+ { status: LeadStatus.BOOKED },
+ { status: LeadStatus.BOOKED },
+ { status: LeadStatus.LOST },
+ ]);
+
+ assert.deepEqual(summary, {
+ totalLeads: 5,
+ closedLeads: 2,
+ lostLeads: 1,
+ openLeads: 2,
+ conversionRate: 40,
+ });
+ assert.equal(formatConversionRate(summary.conversionRate), '40%');
+});
+
+test('lead outcome summary stays readable when no leads exist', () => {
+ assert.deepEqual(getLeadOutcomeSummary([]), {
+ totalLeads: 0,
+ closedLeads: 0,
+ lostLeads: 0,
+ openLeads: 0,
+ conversionRate: 0,
+ });
+});
diff --git a/tests/leads-workspace-routing.test.ts b/tests/leads-workspace-routing.test.ts
index cbc586c..a931ac0 100644
--- a/tests/leads-workspace-routing.test.ts
+++ b/tests/leads-workspace-routing.test.ts
@@ -14,14 +14,18 @@ test('lead inbox stays list-only while lead detail is the main action workspace'
const conversationsPage = read('app/app/conversations/page.tsx');
assert.match(appHomePage, /Who needs follow-up right now\?/);
+ assert.match(appHomePage, /LeadConversionSummaryCard/);
+ assert.match(appHomePage, /getLeadOutcomeSummary/);
assert.match(appHomePage, /Leads needing attention first/);
assert.match(appHomePage, /Recent leads/);
- assert.match(appHomePage, /Missed calls today/);
assert.match(appHomePage, /return `\/app\/leads\/\$\{leadId\}\?from=%2Fapp`/);
assert.match(leadsPage, /Lead inbox/);
+ assert.match(leadsPage, /LeadConversionSummaryCard/);
+ assert.match(leadsPage, /getLeadOutcomeSummary/);
assert.match(leadsPage, /This page is only for scanning/i);
assert.match(leadsPage, /Needs follow-up/);
+ assert.match(leadsPage, /Closed/);
assert.doesNotMatch(leadsPage, /selectedLeadId/);
assert.doesNotMatch(leadsPage, /Lead detail panel/);
assert.doesNotMatch(leadsPage, /Quick actions/);
@@ -30,8 +34,9 @@ test('lead inbox stays list-only while lead detail is the main action workspace'
assert.match(leadDetailPage, /Lead details/);
assert.match(leadDetailPage, /Call now/);
assert.match(leadDetailPage, /Mark contacted/);
- assert.match(leadDetailPage, /Mark booked/);
- assert.match(leadDetailPage, /Mark lost/);
+ assert.match(leadDetailPage, /Mark as Closed \(Won\)/);
+ assert.match(leadDetailPage, /Mark as Lost/);
+ assert.match(leadDetailPage, /Did this lead turn into a real job\?/);
assert.match(leadDetailPage, /Conversation history/);
assert.match(leadDetailPage, /Qualification info/);
assert.match(leadDetailPage, /Missed call details/);
diff --git a/tests/tenant-isolation.test.ts b/tests/tenant-isolation.test.ts
index 0c8fedb..140e330 100644
--- a/tests/tenant-isolation.test.ts
+++ b/tests/tenant-isolation.test.ts
@@ -3,6 +3,7 @@ import test from 'node:test';
import { LeadStatus } from '@prisma/client';
+import { getLeadOutcomeSummary } from '../lib/lead-outcomes.ts';
import {
getBillingUsageSnapshotForBusiness,
getBusinessForOwnerClerkId,
@@ -32,6 +33,13 @@ test('tenant-scoped helpers block cross-business reads and writes while preservi
const allDashboardA = await listAllDashboardLeadsForBusiness(fixtures.businessA.id);
assert.deepEqual(allDashboardA.map((lead) => lead.id), [fixtures.leadA.id]);
+ assert.deepEqual(getLeadOutcomeSummary(allDashboardA), {
+ totalLeads: 1,
+ closedLeads: 0,
+ lostLeads: 0,
+ openLeads: 1,
+ conversionRate: 0,
+ });
const leadDetailA = await getLeadDetailForBusiness(fixtures.businessA.id, fixtures.leadA.id);
assert.equal(leadDetailA?.id, fixtures.leadA.id);
@@ -69,7 +77,7 @@ test('tenant-scoped helpers block cross-business reads and writes while preservi
const blockedUpdate = await updateLeadStatusForBusiness({
businessId: fixtures.businessA.id,
leadId: fixtures.leadB.id,
- status: LeadStatus.BOOKED,
+ status: LeadStatus.LOST,
});
assert.equal(blockedUpdate, null);
@@ -79,9 +87,18 @@ test('tenant-scoped helpers block cross-business reads and writes while preservi
const allowedUpdate = await updateLeadStatusForBusiness({
businessId: fixtures.businessA.id,
leadId: fixtures.leadA.id,
- status: LeadStatus.CONTACTED,
+ status: LeadStatus.BOOKED,
+ });
+ assert.equal(allowedUpdate?.status, LeadStatus.BOOKED);
+
+ const allDashboardAAfterUpdate = await listAllDashboardLeadsForBusiness(fixtures.businessA.id);
+ assert.deepEqual(getLeadOutcomeSummary(allDashboardAAfterUpdate), {
+ totalLeads: 1,
+ closedLeads: 1,
+ lostLeads: 0,
+ openLeads: 0,
+ conversionRate: 100,
});
- assert.equal(allowedUpdate?.status, LeadStatus.CONTACTED);
const ownRecording = await getLeadRecordingForOwnerClerkId({
leadId: fixtures.leadA.id,