mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Rewrites the dashboard page as a data-driven analytics surface using existing Supabase tables only — no schema changes, no other pages touched. Widgets (all render independent skeletons while their data fetches, and show a shared empty state when there's not enough data to be useful): - Metric cards (Active Conversations, New Contacts Today, Open Deals Value, Messages Sent Today) with up/down/no-change delta indicator. - Quick actions row (New Contact / Deal / Broadcast / Automation). - Conversations Over Time: SVG line chart, 7/30/90-day tabs, per-day hover crosshair + tooltip. Cached per range so tab switches are instant. - Pipeline Value: SVG donut with per-stage arc segments, total in the center, legend with deal count + value. - Average First Response Time: SVG bar chart by weekday with a configurable dashed target line and this-week / last-week summary. - Activity feed: latest 20 items merged across messages, contacts, deals, broadcasts, and automation logs — click-through deep-links where applicable. Deviations from spec, noted up-front: - Strictly dark theme — spec's #f9fafb alternating rows translate to slate-900 / slate-900/40 stripes. - Kept Inter (tabular-nums, bold 28px) for metric values instead of Space Grotesk to avoid touching root layout. - Quick actions link to the relevant list pages (/contacts, etc.) rather than auto-opening their create modals — that would require modifying those pages. All aggregation is client-side via RLS-scoped queries. If a tenant's dataset outgrows a few thousand messages per range window, the response-time and line-chart queries should migrate to SQL RPCs — noted as follow-up, not shipped here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
7.4 KiB
TypeScript
219 lines
7.4 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import {
|
|
MessageSquare,
|
|
UserPlus,
|
|
DollarSign,
|
|
Send,
|
|
} from 'lucide-react'
|
|
|
|
import {
|
|
loadActivity,
|
|
loadConversationsSeries,
|
|
loadMetrics,
|
|
loadPipelineDonut,
|
|
loadResponseTime,
|
|
} from '@/lib/dashboard/queries'
|
|
import type {
|
|
ActivityItem,
|
|
ConversationsSeriesPoint,
|
|
MetricsBundle,
|
|
PipelineDonutData,
|
|
ResponseTimeSummary,
|
|
} from '@/lib/dashboard/types'
|
|
|
|
import { MetricCard } from '@/components/dashboard/metric-card'
|
|
import { SkeletonCard } from '@/components/dashboard/skeleton'
|
|
import { QuickActions } from '@/components/dashboard/quick-actions'
|
|
import { ConversationsChart } from '@/components/dashboard/conversations-chart'
|
|
import { PipelineDonut } from '@/components/dashboard/pipeline-donut'
|
|
import { ResponseTimeChart } from '@/components/dashboard/response-time-chart'
|
|
import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
|
|
|
type RangeDays = 7 | 30 | 90
|
|
|
|
export default function DashboardPage() {
|
|
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
|
|
const [metricsLoading, setMetricsLoading] = useState(true)
|
|
|
|
const [range, setRange] = useState<RangeDays>(30)
|
|
// Keep a cache per range so switching tabs doesn't re-fetch what we
|
|
// already have. Ranges the user hasn't opened yet stay null and
|
|
// trigger a fetch on first view.
|
|
const [series, setSeries] = useState<Record<RangeDays, ConversationsSeriesPoint[] | null>>({
|
|
7: null,
|
|
30: null,
|
|
90: null,
|
|
})
|
|
const [seriesLoading, setSeriesLoading] = useState(true)
|
|
|
|
const [pipeline, setPipeline] = useState<PipelineDonutData | null>(null)
|
|
const [pipelineLoading, setPipelineLoading] = useState(true)
|
|
|
|
const [responseTime, setResponseTime] = useState<ResponseTimeSummary | null>(null)
|
|
const [responseTimeLoading, setResponseTimeLoading] = useState(true)
|
|
|
|
const [activity, setActivity] = useState<ActivityItem[] | null>(null)
|
|
const [activityLoading, setActivityLoading] = useState(true)
|
|
|
|
const loadAll = useCallback(() => {
|
|
const db = createClient()
|
|
|
|
// Kick everything off in parallel. Each block has its own
|
|
// setState + finally so a slow query doesn't hold up faster
|
|
// sections — each widget shows its own skeleton independently.
|
|
void loadMetrics(db)
|
|
.then((m) => setMetrics(m))
|
|
.catch((err) => console.error('[dashboard] metrics failed:', err))
|
|
.finally(() => setMetricsLoading(false))
|
|
|
|
void loadConversationsSeries(db, 30)
|
|
.then((s) => setSeries((prev) => ({ ...prev, 30: s })))
|
|
.catch((err) => console.error('[dashboard] series failed:', err))
|
|
.finally(() => setSeriesLoading(false))
|
|
|
|
void loadPipelineDonut(db)
|
|
.then((p) => setPipeline(p))
|
|
.catch((err) => console.error('[dashboard] pipeline failed:', err))
|
|
.finally(() => setPipelineLoading(false))
|
|
|
|
void loadResponseTime(db)
|
|
.then((r) => setResponseTime(r))
|
|
.catch((err) => console.error('[dashboard] response time failed:', err))
|
|
.finally(() => setResponseTimeLoading(false))
|
|
|
|
void loadActivity(db, 20)
|
|
.then((a) => setActivity(a))
|
|
.catch((err) => console.error('[dashboard] activity failed:', err))
|
|
.finally(() => setActivityLoading(false))
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadAll()
|
|
}, [loadAll])
|
|
|
|
// Range switch handler — kept in an event callback (not an effect)
|
|
// so the setState calls stay out of the react-hooks/set-state-in-effect
|
|
// rule's way. The cached bucket check means switching back to a
|
|
// previously-viewed range is instant and doesn't re-fetch.
|
|
const handleRangeChange = useCallback(
|
|
(r: RangeDays) => {
|
|
setRange(r)
|
|
if (series[r] !== null) return
|
|
setSeriesLoading(true)
|
|
const db = createClient()
|
|
loadConversationsSeries(db, r)
|
|
.then((s) => setSeries((prev) => ({ ...prev, [r]: s })))
|
|
.catch((err) => console.error('[dashboard] series failed:', err))
|
|
.finally(() => setSeriesLoading(false))
|
|
},
|
|
[series],
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
|
<p className="mt-1 text-sm text-slate-400">
|
|
Live analytics across conversations, contacts, deals, broadcasts, and automations.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Metric cards */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
{metricsLoading || !metrics ? (
|
|
Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
|
|
) : (
|
|
<>
|
|
<MetricCard
|
|
title="Active Conversations"
|
|
value={metrics.activeConversations.current.toLocaleString()}
|
|
icon={MessageSquare}
|
|
delta={{
|
|
sign: metrics.activeConversations.previous,
|
|
label: deltaLabel(metrics.activeConversations.previous, 'new today vs yesterday'),
|
|
}}
|
|
/>
|
|
<MetricCard
|
|
title="New Contacts Today"
|
|
value={metrics.newContactsToday.current.toLocaleString()}
|
|
icon={UserPlus}
|
|
delta={{
|
|
sign:
|
|
metrics.newContactsToday.current - metrics.newContactsToday.previous,
|
|
label: deltaLabel(
|
|
metrics.newContactsToday.current - metrics.newContactsToday.previous,
|
|
'vs yesterday',
|
|
),
|
|
}}
|
|
/>
|
|
<MetricCard
|
|
title="Open Deals Value"
|
|
value={formatCurrency(metrics.openDealsValue)}
|
|
icon={DollarSign}
|
|
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`}
|
|
/>
|
|
<MetricCard
|
|
title="Messages Sent Today"
|
|
value={metrics.messagesSentToday.current.toLocaleString()}
|
|
icon={Send}
|
|
delta={{
|
|
sign:
|
|
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
|
|
label: deltaLabel(
|
|
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
|
|
'vs yesterday',
|
|
),
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quick actions */}
|
|
<QuickActions />
|
|
|
|
{/* Charts row */}
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
|
<div className="lg:col-span-3">
|
|
<ConversationsChart
|
|
series={series}
|
|
loading={seriesLoading}
|
|
range={range}
|
|
onRangeChange={handleRangeChange}
|
|
/>
|
|
</div>
|
|
<div className="lg:col-span-2">
|
|
<PipelineDonut data={pipeline} loading={pipelineLoading} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Response time */}
|
|
<ResponseTimeChart data={responseTime} loading={responseTimeLoading} />
|
|
|
|
{/* Activity feed */}
|
|
<ActivityFeed items={activity} loading={activityLoading} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
|
|
function formatCurrency(v: number): string {
|
|
return new Intl.NumberFormat(undefined, {
|
|
style: 'currency',
|
|
currency: 'USD',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0,
|
|
}).format(v)
|
|
}
|
|
|
|
function deltaLabel(delta: number, suffix: string): string {
|
|
if (delta === 0) return `No change ${suffix}`
|
|
const sign = delta > 0 ? '+' : ''
|
|
return `${sign}${delta.toLocaleString()} ${suffix}`
|
|
}
|