diff --git a/app/admin/[businessId]/page.tsx b/app/admin/[businessId]/page.tsx index 711c62c..2e86021 100644 --- a/app/admin/[businessId]/page.tsx +++ b/app/admin/[businessId]/page.tsx @@ -9,85 +9,31 @@ import { provisionBusinessAction, resyncBusinessWebhooksAction, restoreBusinessAction, - saveAdminBusinessProfileAction, + saveAdminTwilioSetupAction, sendBusinessTestSmsAction, setBusinessProvisioningStatusAction, } from '@/app/admin/actions'; -import { buildAdminCustomerOpenHref } from '@/lib/admin-customer-paths'; -import { - buildAdminOnboardingConfidence, - canDeleteTestBusiness, - isBusinessArchived, -} from '@/lib/admin-dashboard'; -import { CopyValueButton } from '@/components/copy-value-button'; +import { TwilioSetupChecklist } from '@/components/twilio-setup-checklist'; import { Badge } from '@/components/ui/badge'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select } from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; +import { buildAdminCustomerOpenHref } from '@/lib/admin-customer-paths'; +import { getAdminTestSmsConfidenceState, isBusinessArchived } from '@/lib/admin-dashboard'; +import { getAdminOwnerState, getTwilioWebhookSnapshot, listAdminTwilioNumbers } from '@/lib/admin-provisioning'; import { requireAdmin } from '@/lib/admin'; -import { - adminProvisioningStatusLabels, - buildAdminProvisioningChecklist, - getAdminOwnerState, - getAdminProvisioningStatusVariant, - getTwilioWebhookSnapshot, - listAdminTwilioNumbers, -} from '@/lib/admin-provisioning'; +import { TwilioSetupTone, buildTwilioSetupFlow, twilioAccountModeOptions, twilioNumberSetupModeOptions } from '@/lib/twilio-setup'; import { db } from '@/lib/db'; -import { - formatDateTime, - formatMessageStatus, - formatRelativeTime, - getLeadCallbackState, - getLeadStatusBadgeVariant, - leadReadinessLabels, - leadStatusLabels, -} from '@/lib/lead-presenters'; -import { getManagedTextingNumber, getManagedTwilioStatusSummary, managedTwilioStatusLabels } from '@/lib/managed-twilio-status'; -import { - businessTimelineFilterOptions, - countTimelineFilters, - matchesTimelineFilter, - operatorEventCategoryLabels, - operatorEventStatusLabels, - type BusinessTimelineFilter, -} from '@/lib/operator-events'; +import { getManagedTextingNumber, managedTwilioStatusLabels } from '@/lib/managed-twilio-status'; import { formatPhoneForDisplay } from '@/lib/phone'; -import { getBusinessBillingAccessState } from '@/lib/subscription'; -import { getAdminBusinessStatus, getCustomerSystemStatus } from '@/lib/system-status'; -import { cn } from '@/lib/utils'; - -export const dynamic = 'force-dynamic'; -const changedFieldLabels: Record = { - ownerPhone: 'owner alert phone', - twilioPhoneNumber: 'Twilio number', - twilioPhoneNumberSid: 'Twilio number SID', - twilioMessagingServiceSid: 'messaging service SID', - a2pCustomerProfileSid: 'A2P customer profile SID', - a2pBrandSid: 'A2P brand SID', - a2pCampaignSid: 'A2P campaign SID', - a2pFailureReason: 'A2P failure reason', - managedTwilioStatus: 'managed Twilio status', - isTestBusiness: 'test business flag', -}; - -type AdminBusinessFormDefaults = { - name: string; - ownerName: string; - ownerEmail: string; - ownerPhone: string; - isTestBusiness: boolean; - forwardingNumber: string; - timezone: string; - missedCallSeconds: string; - serviceLabel1: string; - serviceLabel2: string; - serviceLabel3: string; - internalNotes: string; +type AdminTwilioDefaults = { + businessId: string; + twilioAccountMode: string; + twilioNumberSetupMode: string; + twilioSubaccountSid: string; twilioPhoneNumber: string; twilioPhoneNumberSid: string; twilioMessagingServiceSid: string; @@ -96,42 +42,6 @@ type AdminBusinessFormDefaults = { a2pCampaignSid: string; a2pFailureReason: string; managedTwilioStatus: string; - notifySms: boolean; - notifyEmail: boolean; - notifyInApp: boolean; - urgentOnly: boolean; -}; - -type AdminBusinessWithSettings = { - name: string; - ownerName: string | null; - notifyPhone: string | null; - isTestBusiness: boolean; - forwardingNumber: string; - timezone: string; - missedCallSeconds: number; - serviceLabel1: string; - serviceLabel2: string; - serviceLabel3: string; - internalNotes: string | null; - twilioPrimaryPhoneNumber: string | null; - twilioPhoneNumber: string | null; - twilioPrimaryNumberSid: string | null; - twilioPhoneNumberSid: string | null; - twilioMessagingServiceSid: string | null; - a2pCustomerProfileSid: string | null; - a2pBrandSid: string | null; - a2pCampaignSid: string | null; - a2pFailureReason: string | null; - managedTwilioStatus: string; - notificationSettings: { - ownerEmail: string | null; - ownerPhone: string | null; - notifySms: boolean; - notifyEmail: boolean; - notifyInApp: boolean; - urgentOnly: boolean; - } | null; }; function getQueryValue(searchParams: Record | undefined, key: string) { @@ -139,148 +49,28 @@ function getQueryValue(searchParams: Record | undefined): BusinessTimelineFilter { - const value = getQueryValue(searchParams, 'activity'); - return businessTimelineFilterOptions.some((option) => option.key === value) ? (value as BusinessTimelineFilter) : 'all'; -} - -function getOperatorEventBadgeVariant(status: keyof typeof operatorEventStatusLabels) { - if (status === 'FAILED') return 'destructive' as const; - if (status === 'WARNING') return 'outline' as const; - if (status === 'SUCCESS') return 'success' as const; - return 'secondary' as const; -} - -function getConfidenceMilestoneBadgeVariant(variant: 'success' | 'warning' | 'pending') { - if (variant === 'success') return 'success' as const; - if (variant === 'warning') return 'outline' as const; - return 'secondary' as const; -} - -function formatOperatorEventDetailValue(value: unknown): string { - if (value === null || value === undefined) return '-'; - if (typeof value === 'string') return value; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value)) return value.map((item) => formatOperatorEventDetailValue(item)).join(', '); - if (typeof value === 'object') { - return Object.entries(value as Record) - .map(([key, nested]) => `${key}: ${formatOperatorEventDetailValue(nested)}`) - .join(' | '); - } - return String(value); -} - -function getOperatorEventDetails(value: unknown) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return []; - return Object.entries(value as Record).map(([key, detail]) => ({ - key, - label: key.replace(/([A-Z])/g, ' $1').replace(/_/g, ' '), - value: formatOperatorEventDetailValue(detail), - })); -} - -function buildOperatorEventRelatedHref(businessId: string, relatedEntityType: string | null, relatedEntityId: string | null) { - if (!relatedEntityType || !relatedEntityId) return null; - if (relatedEntityType === 'lead') return buildAdminCustomerOpenHref(businessId, `/app/leads/${relatedEntityId}`); - if (relatedEntityType === 'message') return `/admin/${businessId}/workspace#recent-activity`; - if (relatedEntityType === 'call') return buildAdminCustomerOpenHref(businessId, '/app/call-flow'); - return null; -} - -function HiddenAdminBusinessFields({ +function HiddenAdminTwilioFields({ defaults, exclude = [], }: { - defaults: AdminBusinessFormDefaults; - exclude?: Array; + defaults: AdminTwilioDefaults; + exclude?: Array; }) { return ( <> {Object.entries(defaults).map(([name, value]) => { - if (exclude.includes(name as keyof AdminBusinessFormDefaults)) return null; - - if (typeof value === 'boolean') { - if (!value) return null; - return ; - } - + if (exclude.includes(name as keyof AdminTwilioDefaults)) return null; return ; })} ); } -function StatusButton({ - businessId, - status, - label, - variant = 'outline', -}: { - businessId: string; - status: 'DRAFT' | 'ONBOARDING' | 'NEEDS_ATTENTION' | 'LIVE' | 'PAUSED'; - label: string; - variant?: 'default' | 'outline' | 'destructive' | 'secondary'; -}) { - return ( -
- - - -
- ); -} - -function WebhookResyncButton({ - businessId, - target, - label, - variant = 'outline', -}: { - businessId: string; - target: 'VOICE' | 'SMS' | 'ALL'; - label: string; - variant?: 'default' | 'outline' | 'destructive' | 'secondary'; -}) { - return ( -
- - - -
- ); -} - -function buildAdminFormDefaults(business: AdminBusinessWithSettings) { - return { - name: business.name, - ownerName: business.ownerName || '', - ownerEmail: business.notificationSettings?.ownerEmail || '', - ownerPhone: business.notificationSettings?.ownerPhone || business.notifyPhone || '', - isTestBusiness: business.isTestBusiness, - forwardingNumber: business.forwardingNumber, - timezone: business.timezone, - missedCallSeconds: String(business.missedCallSeconds), - serviceLabel1: business.serviceLabel1, - serviceLabel2: business.serviceLabel2, - serviceLabel3: business.serviceLabel3, - internalNotes: business.internalNotes || '', - twilioPhoneNumber: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || '', - twilioPhoneNumberSid: business.twilioPrimaryNumberSid || business.twilioPhoneNumberSid || '', - twilioMessagingServiceSid: business.twilioMessagingServiceSid || '', - a2pCustomerProfileSid: business.a2pCustomerProfileSid || '', - a2pBrandSid: business.a2pBrandSid || '', - a2pCampaignSid: business.a2pCampaignSid || '', - a2pFailureReason: business.a2pFailureReason || '', - managedTwilioStatus: business.managedTwilioStatus, - notifySms: business.notificationSettings?.notifySms ?? true, - notifyEmail: business.notificationSettings?.notifyEmail ?? true, - notifyInApp: business.notificationSettings?.notifyInApp ?? true, - urgentOnly: business.notificationSettings?.urgentOnly ?? false, - } satisfies AdminBusinessFormDefaults; +function getBadgeVariant(tone: TwilioSetupTone) { + if (tone === 'success') return 'success' as const; + if (tone === 'attention') return 'destructive' as const; + if (tone === 'pending') return 'outline' as const; + return 'secondary' as const; } export default async function AdminBusinessDetailPage({ @@ -291,99 +81,36 @@ export default async function AdminBusinessDetailPage({ searchParams?: Record; }) { await requireAdmin(); - const activityFilter = getTimelineFilter(searchParams); - const [business, successfulLeadCount, leadCount, callCount, messageCount, recentLeads, recentCalls, recentMessages, recentOwnerNotifications, operatorEvents] = - await Promise.all([ - db.business.findUnique({ - where: { id: params.businessId }, - include: { - notificationSettings: true, - }, - }), - db.lead.count({ - where: { - businessId: params.businessId, - OR: [{ ownerNotifiedAt: { not: null } }, { notifiedAt: { not: null } }], + const [business, successfulLeadCount, operatorEvents] = await Promise.all([ + db.business.findUnique({ + where: { id: params.businessId }, + include: { + notificationSettings: true, + }, + }), + db.lead.count({ + where: { + businessId: params.businessId, + OR: [{ ownerNotifiedAt: { not: null } }, { notifiedAt: { not: null } }], + }, + }), + db.businessOperatorEvent.findMany({ + where: { + businessId: params.businessId, + type: { + startsWith: 'admin.test_sms_', }, - }), - db.lead.count({ where: { businessId: params.businessId } }), - db.call.count({ where: { businessId: params.businessId } }), - db.message.count({ where: { businessId: params.businessId } }), - db.lead.findMany({ - where: { businessId: params.businessId }, - orderBy: [{ lastInteractionAt: 'desc' }, { createdAt: 'desc' }], - take: 6, - select: { - id: true, - status: true, - readiness: true, - billingRequired: true, - smsState: true, - summary: true, - notifiedAt: true, - ownerNotifiedAt: true, - createdAt: true, - lastInteractionAt: true, - }, - }), - db.call.findMany({ - where: { businessId: params.businessId }, - orderBy: { createdAt: 'desc' }, - take: 6, - select: { - id: true, - status: true, - missed: true, - answered: true, - dialCallStatus: true, - createdAt: true, - }, - }), - db.message.findMany({ - where: { businessId: params.businessId, direction: 'OUTBOUND' }, - orderBy: { createdAt: 'desc' }, - take: 8, - select: { - id: true, - leadId: true, - participant: true, - direction: true, - status: true, - body: true, - createdAt: true, - }, - }), - db.ownerNotification.findMany({ - where: { businessId: params.businessId }, - orderBy: { createdAt: 'desc' }, - take: 8, - select: { - id: true, - channel: true, - status: true, - error: true, - createdAt: true, - destination: true, - }, - }), - db.businessOperatorEvent.findMany({ - where: { businessId: params.businessId }, - orderBy: { createdAt: 'desc' }, - take: 120, - select: { - id: true, - type: true, - category: true, - status: true, - summary: true, - detailsJson: true, - relatedEntityType: true, - relatedEntityId: true, - createdAt: true, - }, - }), - ]); + }, + orderBy: { createdAt: 'desc' }, + take: 20, + select: { + type: true, + status: true, + createdAt: true, + }, + }), + ]); if (!business) notFound(); @@ -393,1080 +120,608 @@ export default async function AdminBusinessDetailPage({ listAdminTwilioNumbers(business), ]); - const rolloutStatus = getAdminBusinessStatus(business, successfulLeadCount); - const customerStatus = getCustomerSystemStatus(business, successfulLeadCount); - const managedSummary = getManagedTwilioStatusSummary(business); - const billingAccess = getBusinessBillingAccessState(business); - const checklist = buildAdminProvisioningChecklist({ - business, - notificationSettings: business.notificationSettings, - ownerConnected: ownerState.connected, - webhookSnapshot, - }); - const onboardingConfidence = buildAdminOnboardingConfidence({ + const testSmsState = getAdminTestSmsConfidenceState(operatorEvents); + const managedTextingNumber = getManagedTextingNumber(business); + const setupFlow = buildTwilioSetupFlow({ business, notificationSettings: business.notificationSettings, ownerConnected: ownerState.connected, successfulLeadCount, - operatorEvents: operatorEvents.map((event) => ({ - type: event.type, - status: event.status, - createdAt: event.createdAt, - })), - }); - const timelineFilterCounts = countTimelineFilters(operatorEvents); - const visibleTimelineEvents = operatorEvents.filter((event) => { - return matchesTimelineFilter(event, activityFilter); + testSmsState, + webhookSnapshot, }); - const assignedNumber = getManagedTextingNumber(business); - const defaults = buildAdminFormDefaults(business); - const webhooksNeedAttention = Boolean( - assignedNumber && - (!business.twilioWebhookSyncedAt || - webhookSnapshot?.voiceSynced === false || - webhookSnapshot?.smsSynced === false || - webhookSnapshot?.statusSynced === false) - ); - const brandStatusLabel = business.a2pBrandSid - ? managedSummary.complianceReady - ? 'Approved' - : managedSummary.attentionRequired - ? 'Needs attention' - : 'Submitted' - : 'Not started'; - const campaignStatusLabel = business.a2pCampaignSid - ? managedSummary.complianceReady - ? 'Approved' - : managedSummary.attentionRequired - ? 'Needs attention' - : 'Pending' - : 'Not started'; - const latestProvisioningEvent = operatorEvents.find((event) => event.category === 'PROVISIONING') || null; - const latestWebhookEvent = - operatorEvents.find((event) => event.category === 'WEBHOOKS') || - (webhooksNeedAttention - ? { - id: 'webhook-attention', - createdAt: business.updatedAt, - status: 'WARNING' as const, - category: 'WEBHOOKS' as const, - summary: 'Webhook mismatch detected', - detailsJson: { - detail: webhookSnapshot?.error || 'Voice, SMS, or status callback sync still needs attention.', - }, - relatedEntityType: null, - relatedEntityId: null, - type: 'webhooks.mismatch_detected', - } - : null); - const latestOutboundSms = recentMessages[0] || null; - const latestOwnerAlert = recentOwnerNotifications[0] || null; + const defaults: AdminTwilioDefaults = { + businessId: business.id, + twilioAccountMode: business.twilioAccountMode, + twilioNumberSetupMode: business.twilioNumberSetupMode, + twilioSubaccountSid: business.twilioSubaccountSid || '', + twilioPhoneNumber: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || '', + twilioPhoneNumberSid: business.twilioPrimaryNumberSid || business.twilioPhoneNumberSid || '', + twilioMessagingServiceSid: business.twilioMessagingServiceSid || '', + a2pCustomerProfileSid: business.a2pCustomerProfileSid || '', + a2pBrandSid: business.a2pBrandSid || '', + a2pCampaignSid: business.a2pCampaignSid || '', + a2pFailureReason: business.a2pFailureReason || '', + managedTwilioStatus: business.managedTwilioStatus, + }; const created = getQueryValue(searchParams, 'created') === '1'; const saved = getQueryValue(searchParams, 'saved') === '1'; const ownerAction = getQueryValue(searchParams, 'ownerAction'); const provisioned = getQueryValue(searchParams, 'provisioned') === '1'; const synced = getQueryValue(searchParams, 'synced'); - const statusSaved = getQueryValue(searchParams, 'statusSaved'); const testSms = getQueryValue(searchParams, 'testSms') === '1'; const archived = getQueryValue(searchParams, 'archived') === '1'; const restored = getQueryValue(searchParams, 'restored') === '1'; const error = getQueryValue(searchParams, 'error'); - const changed = (getQueryValue(searchParams, 'changed') || '') - .split(',') - .map((field) => field.trim()) - .filter(Boolean) - .map((field) => changedFieldLabels[field] || field); - return ( -
-
- - Back to operator board - -
-
-
-

{business.name} business control panel

- {business.isTestBusiness ? Test : null} - {isBusinessArchived(business) ? Archived : null} + const bannerAction = + setupFlow.banner.stepKey === 'owner_connected' && !ownerState.connected ? ( + + Connect owner + + ) : setupFlow.banner.stepKey === 'voice_webhook_synced' || + setupFlow.banner.stepKey === 'sms_webhook_synced' || + setupFlow.banner.stepKey === 'status_callback_synced' ? ( +
+ + + +
+ ) : setupFlow.banner.stepKey === 'safe_to_mark_live' && setupFlow.safeToMarkLive && business.provisioningStatus !== 'LIVE' ? ( +
+ + + +
+ ) : ( + + Review step + + ); + + const setupSteps = setupFlow.steps.map((step) => { + if (step.key === 'owner_connected') { + return { + ...step, + body: ownerState.connected ? ( +
+
+ {ownerState.statusLabel} + {ownerState.email || business.notificationSettings?.ownerEmail || 'Owner email not recorded'}
-

- Compact owner-first workspace for onboarding, fast edits, support shortcuts, and the most common repair actions. -

+

{ownerState.detail}

-
- - Open customer workspace - - - Open customer leads - - - View support workspace snapshot - - - Back to board - -
-
-
- - {adminProvisioningStatusLabels[business.provisioningStatus]} - - {rolloutStatus.label} - {customerStatus.label} - {onboardingConfidence.stateLabel} - {onboardingConfidence.readinessLabel} -
-
- - {created ?
Business workspace created. Review owner setup and provisioning health below.
: null} - {saved ? ( -
- Business details saved{changed.length > 0 ? `: ${changed.join(', ')}.` : '.'} -
- ) : null} - {ownerAction ? ( -
- {ownerAction === 'connected' - ? 'Existing owner connected.' - : ownerAction === 'invited' - ? 'Owner invitation sent.' - : ownerAction === 'resent' - ? 'Owner invitation resent.' - : 'Owner state updated.'} -
- ) : null} - {provisioned ?
Provisioning finished. Review the health cards below.
: null} - {synced ?
Webhook sync complete for {synced.toLowerCase()}.
: null} - {statusSaved ?
Business status updated to {statusSaved.replace(/_/g, ' ')}.
: null} - {testSms ?
Admin test SMS sent.
: null} - {archived ?
Business archived safely. Automation is paused.
: null} - {restored ?
Business restored and ready for review.
: null} - {error ?
{error}
: null} - -
- - - Onboarding confidence - Current state, blockers, next action, and the exact gate between setup, testing, and launch. - - -
-
-
-
-

{onboardingConfidence.stateLabel}

- {onboardingConfidence.readinessLabel} -
-

{onboardingConfidence.summary}

-

Next action: {onboardingConfidence.nextAction}

-

- Confidence checklist: {onboardingConfidence.milestones.filter((item) => item.complete).length} /{' '} - {onboardingConfidence.milestones.length} signals complete -

-
-
- {isBusinessArchived(business) ? ( - - Restore business - - ) : business.provisioningStatus === 'PAUSED' ? ( - - Resume automation - - ) : !ownerState.connected ? ( - - Review owner setup - - ) : !(business.notificationSettings?.ownerPhone || business.notifyPhone) ? ( - - Add owner alert phone - - ) : !managedSummary.subaccountReady || !managedSummary.numberAssigned || !managedSummary.messagingServiceReady ? ( -
- - - -
- ) : webhooksNeedAttention ? ( - - ) : managedSummary.messagingReady && business.provisioningStatus !== 'LIVE' ? ( - - ) : ( - - Open support workspace - - )} - - {!isBusinessArchived(business) ? ( -
- - - -
- ) : null} - {assignedNumber ? : null} -
-
-
- - {onboardingConfidence.blockers.length > 0 ? ( -
- {onboardingConfidence.blockers.map((blocker, index) => ( -
- {blocker.message} -
- ))} -
- ) : null} - - {onboardingConfidence.milestones.length > 0 ? ( -
- {onboardingConfidence.milestones.map((item) => ( -
-
-

{item.label}

- {item.complete ? 'Done' : 'Next'} -
-

{item.detail}

-
- ))} -
- ) : ( -
- No onboarding milestones are available for this business yet. -
- )} - -
- {[ - { label: 'Leads', value: leadCount }, - { label: 'Calls', value: callCount }, - { label: 'Messages', value: messageCount }, - { label: 'Qualified alerts', value: successfulLeadCount }, - ].map((item) => ( -
-

{item.label}

-

{item.value}

-
- ))} -
-
-
- - - - Support mode shortcuts - Open the real customer pages for this business, or choose the snapshot view when you only need read-only context. - - -
- - Open customer workspace - - - Open customer leads - - - Open customer settings - - - Open customer call flow - - - View support workspace snapshot - -
- -
- -
- - -

- {assignedNumber - ? 'Sends a short admin verification message from the assigned business line to the destination above.' - : 'Assign the business number first. Test SMS stays unavailable until the business line is ready.'} -

-
- -
- -
- Support workspace snapshots stay read-only. The buttons above open the real customer pages in an admin-scoped customer mode so you can act without impersonation. -
-
-
-
- -
- - - Business info - Fast edits for the business record, owner identity, and operator notes. - - -
- - -
-
- - -
-
- - -
-
- - -
-
- -