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,