wacrm/src/app/(dashboard)/dashboard/page.tsx
Arnas Donauskas ed669424b6
feat(dashboard): replace placeholder with real analytics
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>
2026-04-17 22:16:00 +03:00

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}`
}