mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
This commit is contained in:
parent
5be2bad51d
commit
ed669424b6
@ -1,428 +1,218 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { Conversation, Pipeline, PipelineStage, Broadcast } from "@/types";
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import {
|
||||
Users,
|
||||
MessageSquare,
|
||||
Mail,
|
||||
TrendingUp,
|
||||
ArrowRight,
|
||||
Radio,
|
||||
GitBranch,
|
||||
UserPlus,
|
||||
DollarSign,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
Send,
|
||||
} from 'lucide-react'
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
import {
|
||||
loadActivity,
|
||||
loadConversationsSeries,
|
||||
loadMetrics,
|
||||
loadPipelineDonut,
|
||||
loadResponseTime,
|
||||
} from '@/lib/dashboard/queries'
|
||||
import type {
|
||||
ActivityItem,
|
||||
ConversationsSeriesPoint,
|
||||
MetricsBundle,
|
||||
PipelineDonutData,
|
||||
ResponseTimeSummary,
|
||||
} from '@/lib/dashboard/types'
|
||||
|
||||
function formatRelativeTime(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
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'
|
||||
|
||||
if (diffMin < 1) return "Just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, loading }: StatCardProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-slate-400">{title}</p>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="mt-3 h-8 w-20 animate-pulse rounded bg-slate-800" />
|
||||
) : (
|
||||
<p className="mt-3 text-3xl font-bold text-white">{value}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PipelineOverviewData {
|
||||
pipeline: Pipeline;
|
||||
stages: (PipelineStage & { dealCount: number; totalValue: number })[];
|
||||
}
|
||||
type RangeDays = 7 | 30 | 90
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const supabase = createClient();
|
||||
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
|
||||
const [metricsLoading, setMetricsLoading] = useState(true)
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalContacts, setTotalContacts] = useState(0);
|
||||
const [openConversations, setOpenConversations] = useState(0);
|
||||
const [messagesToday, setMessagesToday] = useState(0);
|
||||
const [activeDeals, setActiveDeals] = useState(0);
|
||||
const [recentConversations, setRecentConversations] = useState<Conversation[]>([]);
|
||||
const [pipelineOverviews, setPipelineOverviews] = useState<PipelineOverviewData[]>([]);
|
||||
const [recentBroadcasts, setRecentBroadcasts] = useState<Broadcast[]>([]);
|
||||
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(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
loadAll()
|
||||
}, [loadAll])
|
||||
|
||||
async function loadDashboardData() {
|
||||
setLoading(true);
|
||||
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const [
|
||||
contactsRes,
|
||||
openConvRes,
|
||||
messagesTodayRes,
|
||||
dealsRes,
|
||||
recentConvRes,
|
||||
pipelinesRes,
|
||||
broadcastsRes,
|
||||
] = await Promise.all([
|
||||
supabase.from("contacts").select("id", { count: "exact", head: true }),
|
||||
supabase
|
||||
.from("conversations")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("status", "open"),
|
||||
supabase
|
||||
.from("messages")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("created_at", todayStart.toISOString()),
|
||||
supabase.from("deals").select("id", { count: "exact", head: true }),
|
||||
supabase
|
||||
.from("conversations")
|
||||
.select("*, contact:contacts(*)")
|
||||
.order("last_message_at", { ascending: false })
|
||||
.limit(5),
|
||||
supabase.from("pipelines").select("*").order("created_at"),
|
||||
supabase
|
||||
.from("broadcasts")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(3),
|
||||
]);
|
||||
|
||||
setTotalContacts(contactsRes.count ?? 0);
|
||||
setOpenConversations(openConvRes.count ?? 0);
|
||||
setMessagesToday(messagesTodayRes.count ?? 0);
|
||||
setActiveDeals(dealsRes.count ?? 0);
|
||||
setRecentConversations(recentConvRes.data ?? []);
|
||||
setRecentBroadcasts(broadcastsRes.data ?? []);
|
||||
|
||||
// Load pipeline overviews with stages and deal data
|
||||
if (pipelinesRes.data && pipelinesRes.data.length > 0) {
|
||||
const overviews: PipelineOverviewData[] = [];
|
||||
for (const pipeline of pipelinesRes.data) {
|
||||
const { data: stagesData } = await supabase
|
||||
.from("pipeline_stages")
|
||||
.select("*")
|
||||
.eq("pipeline_id", pipeline.id)
|
||||
.order("position");
|
||||
|
||||
const { data: dealsData } = await supabase
|
||||
.from("deals")
|
||||
.select("stage_id, value")
|
||||
.eq("pipeline_id", pipeline.id);
|
||||
|
||||
const enrichedStages = (stagesData || []).map((stage) => {
|
||||
const stageDeals = (dealsData || []).filter(
|
||||
(d) => d.stage_id === stage.id
|
||||
);
|
||||
return {
|
||||
...stage,
|
||||
dealCount: stageDeals.length,
|
||||
totalValue: stageDeals.reduce(
|
||||
(sum: number, d: { value: number }) => sum + d.value,
|
||||
0
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
overviews.push({ pipeline, stages: enrichedStages });
|
||||
}
|
||||
setPipelineOverviews(overviews);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function getBroadcastStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case "sent":
|
||||
return "bg-emerald-500/10 text-emerald-400";
|
||||
case "sending":
|
||||
return "bg-blue-500/10 text-blue-400";
|
||||
case "scheduled":
|
||||
return "bg-yellow-500/10 text-yellow-400";
|
||||
case "draft":
|
||||
return "bg-slate-500/10 text-slate-400";
|
||||
case "failed":
|
||||
return "bg-red-500/10 text-red-400";
|
||||
default:
|
||||
return "bg-slate-500/10 text-slate-400";
|
||||
}
|
||||
}
|
||||
// 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-6">
|
||||
<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">
|
||||
Overview of your WhatsApp CRM activity
|
||||
Live analytics across conversations, contacts, deals, broadcasts, and automations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row 1: Stat Cards */}
|
||||
{/* Metric cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Total Contacts"
|
||||
value={totalContacts.toLocaleString()}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Open Conversations"
|
||||
value={openConversations.toLocaleString()}
|
||||
icon={<MessageSquare className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Messages Today"
|
||||
value={messagesToday.toLocaleString()}
|
||||
icon={<Mail className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Deals"
|
||||
value={activeDeals.toLocaleString()}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* Row 2: Recent Conversations */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-emerald-500" />
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
Recent Conversations
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push("/inbox")}
|
||||
className="flex items-center gap-1 text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</button>
|
||||
{/* 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="divide-y divide-slate-800">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-5 py-3">
|
||||
<div className="h-9 w-9 animate-pulse rounded-full bg-slate-800" />
|
||||
<div className="flex-1">
|
||||
<div className="h-4 w-28 animate-pulse rounded bg-slate-800" />
|
||||
<div className="mt-1.5 h-3 w-48 animate-pulse rounded bg-slate-800" />
|
||||
</div>
|
||||
<div className="h-3 w-12 animate-pulse rounded bg-slate-800" />
|
||||
</div>
|
||||
))
|
||||
) : recentConversations.length === 0 ? (
|
||||
<div className="px-5 py-8 text-center text-sm text-slate-500">
|
||||
No conversations yet
|
||||
</div>
|
||||
) : (
|
||||
recentConversations.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => router.push(`/inbox?c=${conv.id}`)}
|
||||
className="flex w-full items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-slate-800/50"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
||||
{conv.contact?.name?.charAt(0)?.toUpperCase() ||
|
||||
conv.contact?.phone?.slice(-2) ||
|
||||
"?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{conv.contact?.name || conv.contact?.phone || "Unknown"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400 truncate">
|
||||
{conv.last_message_text || "No messages"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<span className="text-xs text-slate-500">
|
||||
{conv.last_message_at
|
||||
? formatRelativeTime(conv.last_message_at)
|
||||
: ""}
|
||||
</span>
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-emerald-500 px-1 text-[10px] font-medium text-white">
|
||||
{conv.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<div className="lg:col-span-2">
|
||||
<PipelineDonut data={pipeline} loading={pipelineLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Pipeline Overview */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center gap-2 border-b border-slate-800 px-5 py-4">
|
||||
<GitBranch className="h-4 w-4 text-emerald-500" />
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
Pipeline Overview
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i}>
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-slate-800" />
|
||||
<div className="mt-3 flex gap-2">
|
||||
{[1, 2, 3, 4].map((j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="h-16 flex-1 animate-pulse rounded-lg bg-slate-800/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : pipelineOverviews.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-slate-500">
|
||||
No pipelines created yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{pipelineOverviews.map(({ pipeline, stages }) => (
|
||||
<div key={pipeline.id}>
|
||||
<h3 className="text-sm font-medium text-white mb-3">
|
||||
{pipeline.name}
|
||||
</h3>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{stages.map((stage) => (
|
||||
<div
|
||||
key={stage.id}
|
||||
className="min-w-[140px] flex-1 rounded-lg border border-slate-800 bg-slate-800/30 p-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: stage.color }}
|
||||
/>
|
||||
<span className="text-xs font-medium text-slate-300 truncate">
|
||||
{stage.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-white">
|
||||
{stage.dealCount}
|
||||
</p>
|
||||
{stage.totalValue > 0 && (
|
||||
<p className="flex items-center gap-1 text-xs text-emerald-400 mt-0.5">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
{formatCurrency(stage.totalValue)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Response time */}
|
||||
<ResponseTimeChart data={responseTime} loading={responseTimeLoading} />
|
||||
|
||||
{/* Row 4: Recent Broadcasts */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center gap-2 border-b border-slate-800 px-5 py-4">
|
||||
<Radio className="h-4 w-4 text-emerald-500" />
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
Recent Broadcasts
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-800">
|
||||
{loading ? (
|
||||
Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-5 py-3">
|
||||
<div className="flex-1">
|
||||
<div className="h-4 w-36 animate-pulse rounded bg-slate-800" />
|
||||
<div className="mt-1.5 h-3 w-24 animate-pulse rounded bg-slate-800" />
|
||||
</div>
|
||||
<div className="h-5 w-16 animate-pulse rounded-full bg-slate-800" />
|
||||
</div>
|
||||
))
|
||||
) : recentBroadcasts.length === 0 ? (
|
||||
<div className="px-5 py-8 text-center text-sm text-slate-500">
|
||||
No broadcasts yet
|
||||
</div>
|
||||
) : (
|
||||
recentBroadcasts.map((broadcast) => (
|
||||
<div
|
||||
key={broadcast.id}
|
||||
className="flex items-center gap-4 px-5 py-3"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800 text-slate-400">
|
||||
<Radio className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{broadcast.name}
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-3 text-xs text-slate-400">
|
||||
<span>
|
||||
{broadcast.sent_count}/{broadcast.total_recipients} sent
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatRelativeTime(broadcast.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium capitalize ${getBroadcastStatusColor(
|
||||
broadcast.status
|
||||
)}`}
|
||||
>
|
||||
{broadcast.status}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 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}`
|
||||
}
|
||||
|
||||
117
src/components/dashboard/activity-feed.tsx
Normal file
117
src/components/dashboard/activity-feed.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
MessageSquare,
|
||||
UserPlus,
|
||||
Briefcase,
|
||||
Radio,
|
||||
Zap,
|
||||
Inbox,
|
||||
} from 'lucide-react'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { ActivityItem, ActivityKind } from '@/lib/dashboard/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { EmptyState } from './empty-state'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
interface ActivityFeedProps {
|
||||
items: ActivityItem[] | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
interface KindTheme {
|
||||
icon: ComponentType<{ className?: string }>
|
||||
/** Tailwind classes for the round icon badge + label color. */
|
||||
badge: string
|
||||
}
|
||||
|
||||
const KIND_THEME: Record<ActivityKind, KindTheme> = {
|
||||
message: { icon: MessageSquare, badge: 'bg-blue-500/10 text-blue-400' },
|
||||
contact: { icon: UserPlus, badge: 'bg-emerald-500/10 text-emerald-400' },
|
||||
deal: { icon: Briefcase, badge: 'bg-violet-500/10 text-violet-400' },
|
||||
broadcast: { icon: Radio, badge: 'bg-amber-500/10 text-amber-400' },
|
||||
automation: { icon: Zap, badge: 'bg-rose-500/10 text-rose-400' },
|
||||
}
|
||||
|
||||
export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-white">Recent Activity</h2>
|
||||
<Link
|
||||
href="/inbox"
|
||||
className="text-xs font-medium text-emerald-400 hover:text-emerald-300"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{loading || !items ? (
|
||||
<div className="space-y-2 p-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-5">
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No activity yet"
|
||||
hint="Activity from messages, deals, broadcasts, and automations will appear here."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-800">
|
||||
{items.map((it, i) => {
|
||||
const theme = KIND_THEME[it.kind]
|
||||
const Icon = theme.icon
|
||||
// Alternating row background for light scanability — dark-theme
|
||||
// translation of the spec's white / #f9fafb stripes.
|
||||
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-slate-900/40'
|
||||
const row = (
|
||||
<div className="flex items-center gap-3 px-5 py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full',
|
||||
theme.badge,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-slate-200">
|
||||
{it.text}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-xs text-slate-500 tabular-nums">
|
||||
{relativeTime(it.at)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<li key={it.id} className={cn(stripe, 'transition-colors hover:bg-slate-800/40')}>
|
||||
{it.href ? (
|
||||
<Link href={it.href} className="block">
|
||||
{row}
|
||||
</Link>
|
||||
) : (
|
||||
row
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime()
|
||||
if (Number.isNaN(then)) return ''
|
||||
const diffSec = Math.round((Date.now() - then) / 1000)
|
||||
if (diffSec < 60) return `${Math.max(1, diffSec)}s ago`
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
|
||||
if (diffSec < 2_592_000) return `${Math.floor(diffSec / 86400)}d ago`
|
||||
return new Date(iso).toLocaleDateString()
|
||||
}
|
||||
305
src/components/dashboard/conversations-chart.tsx
Normal file
305
src/components/dashboard/conversations-chart.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MessageSquare } from 'lucide-react'
|
||||
import type { ConversationsSeriesPoint } from '@/lib/dashboard/types'
|
||||
import { EmptyState } from './empty-state'
|
||||
import { Skeleton } from './skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type RangeDays = 7 | 30 | 90
|
||||
|
||||
interface ConversationsChartProps {
|
||||
/** Per-range data, so switching tabs never re-fetches. */
|
||||
series: Record<RangeDays, ConversationsSeriesPoint[] | null>
|
||||
loading: boolean
|
||||
range: RangeDays
|
||||
onRangeChange: (r: RangeDays) => void
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Layout constants. The SVG renders into a fixed viewBox and scales
|
||||
// via CSS (preserveAspectRatio default). Everything inside uses
|
||||
// viewBox coordinates so the drawing math stays simple even as the
|
||||
// container resizes.
|
||||
// ------------------------------------------------------------
|
||||
const VB_W = 760
|
||||
const VB_H = 240
|
||||
const PADDING = { top: 16, right: 16, bottom: 28, left: 40 }
|
||||
|
||||
export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) {
|
||||
const data = series[range]
|
||||
|
||||
// Memoise the max so per-day hover math doesn't recompute it.
|
||||
const { maxY, niceTicks } = useMemo(() => {
|
||||
const arr = data ?? []
|
||||
const max = arr.reduce(
|
||||
(m, p) => Math.max(m, p.incoming, p.outgoing),
|
||||
0,
|
||||
)
|
||||
const ceil = niceCeil(max)
|
||||
const ticks = [0, ceil / 4, ceil / 2, (3 * ceil) / 4, ceil].map((v) =>
|
||||
Math.round(v),
|
||||
)
|
||||
// De-dupe when the series is flat 0.
|
||||
return { maxY: ceil, niceTicks: Array.from(new Set(ticks)) }
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">Conversations Over Time</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-500">Daily message volume by direction</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-lg bg-slate-800/60 p-1">
|
||||
{[7, 30, 90].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => onRangeChange(r as RangeDays)}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
range === r
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{r} days
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-5">
|
||||
{loading || !data ? (
|
||||
<Skeleton className="h-[240px] w-full" />
|
||||
) : data.every((p) => p.incoming === 0 && p.outgoing === 0) ? (
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
title="No message activity in this range"
|
||||
hint="Send or receive messages to start populating this chart."
|
||||
/>
|
||||
) : (
|
||||
<LineSvg data={data} maxY={maxY} ticks={niceTicks} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center gap-4 border-t border-slate-800 px-5 py-3 text-xs text-slate-400">
|
||||
<LegendDot color="#3b82f6" label="Incoming" />
|
||||
<LegendDot color="#10b981" label="Outgoing" />
|
||||
</footer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// The actual SVG. Two polylines + per-day hit targets for hover.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
function LineSvg({
|
||||
data,
|
||||
maxY,
|
||||
ticks,
|
||||
}: {
|
||||
data: ConversationsSeriesPoint[]
|
||||
maxY: number
|
||||
ticks: number[]
|
||||
}) {
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
const chartW = VB_W - PADDING.left - PADDING.right
|
||||
const chartH = VB_H - PADDING.top - PADDING.bottom
|
||||
|
||||
// x step can be fractional for 90-day views; points are positioned
|
||||
// at the center of each "slot" so the first and last points don't
|
||||
// sit right on the axis.
|
||||
const stepX = data.length > 1 ? chartW / (data.length - 1) : 0
|
||||
const yFor = (v: number) =>
|
||||
maxY === 0 ? PADDING.top + chartH : PADDING.top + chartH - (v / maxY) * chartH
|
||||
const xFor = (i: number) => PADDING.left + i * stepX
|
||||
|
||||
const incomingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.incoming)}`).join(' ')
|
||||
const outgoingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.outgoing)}`).join(' ')
|
||||
|
||||
// Mouse-move: snap to nearest data-point index.
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current
|
||||
if (!svg) return
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const xPct = (e.clientX - rect.left) / rect.width
|
||||
const xPx = xPct * VB_W
|
||||
if (xPx < PADDING.left - 8 || xPx > VB_W - PADDING.right + 8) {
|
||||
setHoverIdx(null)
|
||||
return
|
||||
}
|
||||
const local = xPx - PADDING.left
|
||||
const idx = Math.round(stepX === 0 ? 0 : local / stepX)
|
||||
setHoverIdx(Math.max(0, Math.min(data.length - 1, idx)))
|
||||
}
|
||||
const onLeave = () => setHoverIdx(null)
|
||||
svg.addEventListener('mousemove', onMove)
|
||||
svg.addEventListener('mouseleave', onLeave)
|
||||
return () => {
|
||||
svg.removeEventListener('mousemove', onMove)
|
||||
svg.removeEventListener('mouseleave', onLeave)
|
||||
}
|
||||
}, [data, stepX])
|
||||
|
||||
const hovered = hoverIdx !== null ? data[hoverIdx] : null
|
||||
const hoverX = hoverIdx !== null ? xFor(hoverIdx) : 0
|
||||
|
||||
// X-axis label strategy: show ~6 evenly-spaced labels regardless
|
||||
// of range so the axis never looks crowded.
|
||||
const labelStride = Math.max(1, Math.ceil(data.length / 6))
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${VB_W} ${VB_H}`}
|
||||
className="h-[240px] w-full"
|
||||
role="img"
|
||||
aria-label="Conversations per day"
|
||||
>
|
||||
{/* Y-axis gridlines + labels */}
|
||||
{ticks.map((t) => {
|
||||
const y = yFor(t)
|
||||
return (
|
||||
<g key={t}>
|
||||
<line
|
||||
x1={PADDING.left}
|
||||
x2={VB_W - PADDING.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="rgb(30 41 59)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={PADDING.left - 8}
|
||||
y={y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
className="fill-slate-500 text-[10px]"
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{data.map((p, i) =>
|
||||
i % labelStride === 0 ? (
|
||||
<text
|
||||
key={p.day}
|
||||
x={xFor(i)}
|
||||
y={VB_H - 8}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-[10px]"
|
||||
>
|
||||
{shortDayLabel(p.day)}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
{/* Outgoing polyline (emerald) */}
|
||||
<path
|
||||
d={outgoingPath}
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Incoming polyline (blue) */}
|
||||
<path
|
||||
d={incomingPath}
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Hover crosshair */}
|
||||
{hoverIdx !== null && (
|
||||
<g pointerEvents="none">
|
||||
<line
|
||||
x1={hoverX}
|
||||
x2={hoverX}
|
||||
y1={PADDING.top}
|
||||
y2={PADDING.top + chartH}
|
||||
stroke="rgb(71 85 105)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<circle cx={hoverX} cy={yFor(data[hoverIdx].incoming)} r={3.5} fill="#3b82f6" />
|
||||
<circle cx={hoverX} cy={yFor(data[hoverIdx].outgoing)} r={3.5} fill="#10b981" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Tooltip — absolute-positioned div so we get crisp text, not SVG */}
|
||||
{hovered && hoverIdx !== null && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-slate-700 bg-slate-950 px-2.5 py-1.5 text-[11px] shadow-lg"
|
||||
style={{ left: `${(hoverX / VB_W) * 100}%` }}
|
||||
>
|
||||
<div className="font-medium text-white">{longDayLabel(hovered.day)}</div>
|
||||
<div className="mt-1 flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-blue-300">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
{hovered.incoming} incoming
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-emerald-300">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
{hovered.outgoing} outgoing
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full" style={{ background: color }} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function shortDayLabel(key: string): string {
|
||||
// key is YYYY-MM-DD; return "Apr 17"-style. Using Date with an
|
||||
// appended time avoids timezone-shift surprises across midnight.
|
||||
const [y, m, d] = key.split('-').map(Number)
|
||||
const date = new Date(y, m - 1, d)
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function longDayLabel(key: string): string {
|
||||
const [y, m, d] = key.split('-').map(Number)
|
||||
const date = new Date(y, m - 1, d)
|
||||
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Round `max` up to a "nice" number so Y-axis ticks feel natural
|
||||
* (1, 2, 5, 10, 20, 50, …). Keeps the chart readable even when the
|
||||
* series is small (max=3 becomes ceil=4, not 3).
|
||||
*/
|
||||
function niceCeil(max: number): number {
|
||||
if (max <= 0) return 4
|
||||
const pow = Math.pow(10, Math.floor(Math.log10(max)))
|
||||
const normalised = max / pow
|
||||
let nice: number
|
||||
if (normalised <= 1) nice = 1
|
||||
else if (normalised <= 2) nice = 2
|
||||
else if (normalised <= 5) nice = 5
|
||||
else nice = 10
|
||||
return nice * pow
|
||||
}
|
||||
36
src/components/dashboard/empty-state.tsx
Normal file
36
src/components/dashboard/empty-state.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { BarChart3 } from 'lucide-react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Shared empty-state panel for charts that can't render meaningfully
|
||||
* without a minimum amount of data. Kept minimal and uniform so the
|
||||
* three empty states on the dashboard don't each feel like a
|
||||
* different widget.
|
||||
*/
|
||||
export function EmptyState({
|
||||
title = 'Not enough data yet',
|
||||
hint,
|
||||
icon: Icon = BarChart3,
|
||||
className,
|
||||
}: {
|
||||
title?: string
|
||||
hint?: string
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-40 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-slate-800 bg-slate-900/40 px-4 py-6 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-800 text-slate-500">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-300">{title}</p>
|
||||
{hint && <p className="max-w-xs text-xs text-slate-500">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
src/components/dashboard/metric-card.tsx
Normal file
57
src/components/dashboard/metric-card.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { ArrowDown, ArrowUp, Minus } from 'lucide-react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string
|
||||
/** Pre-formatted value for display (e.g. "42" or "$1,250"). */
|
||||
value: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
/**
|
||||
* Delta-mode secondary row: arrow + delta text. Omit when the metric
|
||||
* doesn't have a sensible comparison (e.g. total pipeline value).
|
||||
*/
|
||||
delta?: {
|
||||
/** Positive / negative / zero drives arrow + color. */
|
||||
sign: number
|
||||
/** Pre-formatted delta, e.g. "+3 vs yesterday". */
|
||||
label: string
|
||||
}
|
||||
/** Used instead of `delta` when the metric has a static subtitle. */
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
export function MetricCard({ title, value, icon: Icon, delta, subtitle }: MetricCardProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-sm font-medium text-slate-400">{title}</p>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-800 text-slate-500">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-[28px] leading-none font-bold tabular-nums text-white">
|
||||
{value}
|
||||
</p>
|
||||
{delta ? <DeltaRow sign={delta.sign} label={delta.label} /> : subtitle ? (
|
||||
<p className="mt-2 text-sm text-slate-500">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeltaRow({ sign, label }: { sign: number; label: string }) {
|
||||
const tone =
|
||||
sign > 0
|
||||
? 'text-emerald-400'
|
||||
: sign < 0
|
||||
? 'text-red-400'
|
||||
: 'text-slate-500'
|
||||
const Arrow = sign > 0 ? ArrowUp : sign < 0 ? ArrowDown : Minus
|
||||
return (
|
||||
<div className={cn('mt-2 flex items-center gap-1 text-sm', tone)}>
|
||||
<Arrow className="h-4 w-4" aria-hidden />
|
||||
<span className="tabular-nums">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
src/components/dashboard/pipeline-donut.tsx
Normal file
144
src/components/dashboard/pipeline-donut.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { GitBranch } from 'lucide-react'
|
||||
import type { PipelineDonutData } from '@/lib/dashboard/types'
|
||||
import { EmptyState } from './empty-state'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
interface PipelineDonutProps {
|
||||
data: PipelineDonutData | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function PipelineDonut({ data, loading }: PipelineDonutProps) {
|
||||
return (
|
||||
<section className="flex flex-col rounded-xl border border-slate-800 bg-slate-900">
|
||||
<header className="border-b border-slate-800 px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-white">Pipeline Value</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
Open deals by stage
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
{loading || !data ? (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
) : data.stages.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={GitBranch}
|
||||
title="No open deals yet"
|
||||
hint="Create deals in Pipelines to see stage breakdowns here."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Donut data={data} />
|
||||
<ul className="mt-5 space-y-2">
|
||||
{data.stages.map((s) => (
|
||||
<li key={s.id} className="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
style={{ background: s.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex-1 truncate text-slate-300">{s.name}</span>
|
||||
<span className="text-slate-500 tabular-nums">
|
||||
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span className="w-20 text-right text-slate-300 tabular-nums">
|
||||
{formatCurrencyShort(s.totalValue)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// SVG ring. 200×200 viewBox, 12px ring width. We draw one <path>
|
||||
// per stage using an SVG arc from startAngle → endAngle. Gaps
|
||||
// between segments are implied by a thin slate-900 stroke between
|
||||
// them for a cleaner look.
|
||||
// ------------------------------------------------------------
|
||||
function Donut({ data }: { data: PipelineDonutData }) {
|
||||
const size = 200
|
||||
const r = 80
|
||||
const ringWidth = 18
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
|
||||
// Small slices would render as slivers that disappear into stroke
|
||||
// rounding. We give each stage a floor share purely for rendering,
|
||||
// but keep the labels/legend honest with the actual totals.
|
||||
const totalRaw = data.totalValue || 1
|
||||
const minFrac = 0.02
|
||||
const rawShares = data.stages.map((s) => s.totalValue / totalRaw)
|
||||
const floored = rawShares.map((x) => Math.max(x, minFrac))
|
||||
const floorSum = floored.reduce((a, b) => a + b, 0)
|
||||
const shares = floored.map((x) => x / floorSum)
|
||||
|
||||
// Build a cumulative-offset array, then map stages → arc paths. Using
|
||||
// a pre-computed offsets array avoids the Next 16 React Compiler's
|
||||
// "Cannot reassign variable after render completes" rule.
|
||||
const offsets: number[] = [0]
|
||||
for (let i = 0; i < shares.length; i++) offsets.push(offsets[i] + shares[i])
|
||||
const segments = data.stages.map((s, i) => {
|
||||
const start = offsets[i] * Math.PI * 2 - Math.PI / 2
|
||||
const end = offsets[i + 1] * Math.PI * 2 - Math.PI / 2
|
||||
return { path: arcPath(cx, cy, r, start, end), color: s.color, id: s.id }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage">
|
||||
{/* background ring */}
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="rgb(30 41 59)" strokeWidth={ringWidth} />
|
||||
{segments.map((seg) => (
|
||||
<path
|
||||
key={seg.id}
|
||||
d={seg.path}
|
||||
fill="none"
|
||||
stroke={seg.color}
|
||||
strokeWidth={ringWidth}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
))}
|
||||
{/* center label */}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy - 6}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-[11px]"
|
||||
>
|
||||
Total
|
||||
</text>
|
||||
<text
|
||||
x={cx}
|
||||
y={cy + 14}
|
||||
textAnchor="middle"
|
||||
className="fill-white text-[18px] font-semibold tabular-nums"
|
||||
>
|
||||
{formatCurrencyShort(data.totalValue)}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function arcPath(cx: number, cy: number, r: number, startRad: number, endRad: number): string {
|
||||
const x1 = cx + r * Math.cos(startRad)
|
||||
const y1 = cy + r * Math.sin(startRad)
|
||||
const x2 = cx + r * Math.cos(endRad)
|
||||
const y2 = cy + r * Math.sin(endRad)
|
||||
const largeArc = endRad - startRad > Math.PI ? 1 : 0
|
||||
return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`
|
||||
}
|
||||
|
||||
function formatCurrencyShort(v: number): string {
|
||||
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`
|
||||
if (v >= 1_000) return `$${(v / 1_000).toFixed(1)}k`
|
||||
return `$${v.toFixed(0)}`
|
||||
}
|
||||
45
src/components/dashboard/quick-actions.tsx
Normal file
45
src/components/dashboard/quick-actions.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import Link from 'next/link'
|
||||
import { UserPlus, Briefcase, Radio, Zap } from 'lucide-react'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
// Quick-action shortcuts. Each navigates to the page that owns the
|
||||
// relevant "create" flow. We deliberately don't try to auto-open any
|
||||
// modal on the target page — that'd require touching those pages,
|
||||
// which is out of scope here.
|
||||
interface Action {
|
||||
label: string
|
||||
href: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
tint: string
|
||||
}
|
||||
|
||||
const ACTIONS: Action[] = [
|
||||
{ label: 'New Contact', href: '/contacts', icon: UserPlus, tint: 'text-emerald-400' },
|
||||
{ label: 'New Deal', href: '/pipelines', icon: Briefcase, tint: 'text-blue-400' },
|
||||
{ label: 'New Broadcast', href: '/broadcasts/new', icon: Radio, tint: 'text-amber-400' },
|
||||
{ label: 'New Automation', href: '/automations/new', icon: Zap, tint: 'text-violet-400' },
|
||||
]
|
||||
|
||||
export function QuickActions() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{ACTIONS.map((a) => {
|
||||
const Icon = a.icon
|
||||
return (
|
||||
<Link
|
||||
key={a.href}
|
||||
href={a.href}
|
||||
className="group flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-900 px-4 py-3 transition-colors hover:border-slate-700 hover:bg-slate-800/60"
|
||||
>
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-slate-800 ${a.tint}`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white">{a.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
202
src/components/dashboard/response-time-chart.tsx
Normal file
202
src/components/dashboard/response-time-chart.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
"use client"
|
||||
|
||||
import { Clock } from 'lucide-react'
|
||||
import { DOW_SHORT_MON_FIRST } from '@/lib/dashboard/date-utils'
|
||||
import type { ResponseTimeSummary } from '@/lib/dashboard/types'
|
||||
import { EmptyState } from './empty-state'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
interface ResponseTimeChartProps {
|
||||
data: ResponseTimeSummary | null
|
||||
loading: boolean
|
||||
/** Minutes. Horizontal dashed line rendered at this height. */
|
||||
thresholdMinutes?: number
|
||||
}
|
||||
|
||||
const VB_W = 760
|
||||
const VB_H = 220
|
||||
const PADDING = { top: 24, right: 16, bottom: 32, left: 44 }
|
||||
|
||||
export function ResponseTimeChart({
|
||||
data,
|
||||
loading,
|
||||
thresholdMinutes = 5,
|
||||
}: ResponseTimeChartProps) {
|
||||
const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
Average First Response Time
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
Minutes to reply to a customer's first unreplied message, by
|
||||
weekday
|
||||
</p>
|
||||
</div>
|
||||
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
|
||||
<div className="text-right text-xs">
|
||||
<div className="text-slate-400">
|
||||
This week:{' '}
|
||||
<span className="font-medium text-white tabular-nums">
|
||||
{fmt(data.thisWeekAvg)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
Last week:{' '}
|
||||
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="p-5">
|
||||
{loading || !data ? (
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
) : !hasData ? (
|
||||
<EmptyState
|
||||
icon={Clock}
|
||||
title="No replies recorded yet"
|
||||
hint="This chart fills in as you reply to customer messages."
|
||||
/>
|
||||
) : (
|
||||
<Bars data={data} thresholdMinutes={thresholdMinutes} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Bars({
|
||||
data,
|
||||
thresholdMinutes,
|
||||
}: {
|
||||
data: ResponseTimeSummary
|
||||
thresholdMinutes: number
|
||||
}) {
|
||||
const chartW = VB_W - PADDING.left - PADDING.right
|
||||
const chartH = VB_H - PADDING.top - PADDING.bottom
|
||||
|
||||
const values = data.buckets.map((b) => b.avgMinutes ?? 0)
|
||||
const rawMax = Math.max(thresholdMinutes * 1.2, ...values)
|
||||
const maxY = niceCeil(rawMax)
|
||||
const yFor = (v: number) =>
|
||||
maxY === 0 ? PADDING.top + chartH : PADDING.top + chartH - (v / maxY) * chartH
|
||||
|
||||
const barSlot = chartW / 7
|
||||
const barW = Math.min(44, barSlot * 0.55)
|
||||
|
||||
const ticks = [0, maxY / 2, maxY].map((t) => Math.round(t))
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="h-[220px] w-full" role="img">
|
||||
{/* Y grid */}
|
||||
{ticks.map((t) => {
|
||||
const y = yFor(t)
|
||||
return (
|
||||
<g key={t}>
|
||||
<line
|
||||
x1={PADDING.left}
|
||||
x2={VB_W - PADDING.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="rgb(30 41 59)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={PADDING.left - 8}
|
||||
y={y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
className="fill-slate-500 text-[10px]"
|
||||
>
|
||||
{t}m
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Threshold line — rendered after grid so it sits on top, but
|
||||
we keep it muted so bars remain the visual focus. */}
|
||||
{thresholdMinutes > 0 && thresholdMinutes <= maxY && (
|
||||
<g>
|
||||
<line
|
||||
x1={PADDING.left}
|
||||
x2={VB_W - PADDING.right}
|
||||
y1={yFor(thresholdMinutes)}
|
||||
y2={yFor(thresholdMinutes)}
|
||||
stroke="rgb(244 63 94)"
|
||||
strokeDasharray="4 4"
|
||||
strokeWidth={1.25}
|
||||
opacity={0.8}
|
||||
/>
|
||||
<text
|
||||
x={VB_W - PADDING.right - 4}
|
||||
y={yFor(thresholdMinutes) - 4}
|
||||
textAnchor="end"
|
||||
className="fill-rose-300 text-[10px]"
|
||||
>
|
||||
target {thresholdMinutes}m
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Bars */}
|
||||
{data.buckets.map((b, i) => {
|
||||
const v = b.avgMinutes ?? 0
|
||||
const x = PADDING.left + barSlot * i + (barSlot - barW) / 2
|
||||
const y = yFor(v)
|
||||
const h = PADDING.top + chartH - y
|
||||
const muted = b.avgMinutes == null
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect
|
||||
x={x}
|
||||
y={muted ? PADDING.top + chartH - 2 : y}
|
||||
width={barW}
|
||||
height={muted ? 2 : Math.max(1, h)}
|
||||
rx={4}
|
||||
fill={muted ? 'rgb(51 65 85)' : '#10b981'}
|
||||
opacity={muted ? 0.6 : 1}
|
||||
>
|
||||
<title>
|
||||
{DOW_SHORT_MON_FIRST[i]}:{' '}
|
||||
{b.avgMinutes == null ? 'no samples' : `${b.avgMinutes.toFixed(1)} min avg`}
|
||||
{b.samples > 0 ? ` (${b.samples} sample${b.samples === 1 ? '' : 's'})` : ''}
|
||||
</title>
|
||||
</rect>
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={VB_H - 10}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-400 text-[11px]"
|
||||
>
|
||||
{DOW_SHORT_MON_FIRST[i]}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function fmt(mins: number | null): string {
|
||||
if (mins == null) return '—'
|
||||
if (mins < 1) return `${Math.max(1, Math.round(mins * 60))}s`
|
||||
if (mins < 60) return `${mins.toFixed(1)}m`
|
||||
return `${(mins / 60).toFixed(1)}h`
|
||||
}
|
||||
|
||||
function niceCeil(max: number): number {
|
||||
if (max <= 0) return 10
|
||||
const pow = Math.pow(10, Math.floor(Math.log10(max)))
|
||||
const n = max / pow
|
||||
let nice: number
|
||||
if (n <= 1) nice = 1
|
||||
else if (n <= 2) nice = 2
|
||||
else if (n <= 5) nice = 5
|
||||
else nice = 10
|
||||
return nice * pow
|
||||
}
|
||||
25
src/components/dashboard/skeleton.tsx
Normal file
25
src/components/dashboard/skeleton.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Shared skeleton primitive — a pulsing slate block sized to whatever
|
||||
* container it's dropped into. Used by every dashboard widget while
|
||||
* its data fetches.
|
||||
*/
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-slate-800', className)} />
|
||||
}
|
||||
|
||||
export function SkeletonCard({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-slate-800 bg-slate-900 p-5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="mt-4 h-8 w-20" />
|
||||
<Skeleton className="mt-2 h-3 w-16" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
src/lib/dashboard/date-utils.ts
Normal file
52
src/lib/dashboard/date-utils.ts
Normal file
@ -0,0 +1,52 @@
|
||||
// Centralised date helpers for the dashboard so every chart / card
|
||||
// agrees on what "today", "day boundary", and "day of week" mean.
|
||||
// All boundaries are computed in the user's LOCAL timezone — which is
|
||||
// what a business user intuitively expects when they say "today".
|
||||
|
||||
export function startOfLocalDay(d: Date = new Date()): Date {
|
||||
const out = new Date(d)
|
||||
out.setHours(0, 0, 0, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
export function daysAgoStart(days: number): Date {
|
||||
const out = startOfLocalDay()
|
||||
out.setDate(out.getDate() - days)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Date-only key (YYYY-MM-DD) for bucketing rows by local calendar day. */
|
||||
export function localDayKey(d: Date | string): string {
|
||||
const date = typeof d === 'string' ? new Date(d) : d
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive list of local-day keys spanning the last `n` days, in
|
||||
* chronological order. Useful for seeding chart buckets so days with
|
||||
* zero activity still render a 0-point in the line.
|
||||
*/
|
||||
export function lastNDayKeys(n: number): string[] {
|
||||
const keys: string[] = []
|
||||
const start = daysAgoStart(n - 1)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = new Date(start)
|
||||
d.setDate(d.getDate() + i)
|
||||
keys.push(localDayKey(d))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO day-of-week where 0 = Monday … 6 = Sunday. JavaScript's native
|
||||
* getDay() uses 0 = Sunday which is awkward for most business charts.
|
||||
*/
|
||||
export function mondayIndex(d: Date): number {
|
||||
const jsDow = d.getDay() // 0..6 with Sunday=0
|
||||
return (jsDow + 6) % 7
|
||||
}
|
||||
|
||||
export const DOW_SHORT_MON_FIRST = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const
|
||||
398
src/lib/dashboard/queries.ts
Normal file
398
src/lib/dashboard/queries.ts
Normal file
@ -0,0 +1,398 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
daysAgoStart,
|
||||
DOW_SHORT_MON_FIRST,
|
||||
lastNDayKeys,
|
||||
localDayKey,
|
||||
mondayIndex,
|
||||
startOfLocalDay,
|
||||
} from './date-utils'
|
||||
import type {
|
||||
ActivityItem,
|
||||
ConversationsSeriesPoint,
|
||||
MetricsBundle,
|
||||
PipelineDonutData,
|
||||
PipelineStageSlice,
|
||||
ResponseTimeBucket,
|
||||
ResponseTimeSummary,
|
||||
} from './types'
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// All client-side aggregation. RLS scopes every query to the
|
||||
// signed-in user automatically, so we never pass user_id explicitly
|
||||
// here. Perf is acceptable for the current scale (low thousands of
|
||||
// messages) — if a tenant's dataset outgrows this, we'd migrate the
|
||||
// heavy aggregations to SQL RPCs. Noted in the PR.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type DB = SupabaseClient
|
||||
|
||||
// --- 1. Metric cards ---------------------------------------------------
|
||||
|
||||
export async function loadMetrics(db: DB): Promise<MetricsBundle> {
|
||||
const todayStart = startOfLocalDay().toISOString()
|
||||
const yesterdayStart = daysAgoStart(1).toISOString()
|
||||
|
||||
const [
|
||||
openConvCur,
|
||||
newConvToday,
|
||||
newConvYesterday,
|
||||
newContactsToday,
|
||||
newContactsYesterday,
|
||||
openDeals,
|
||||
messagesToday,
|
||||
messagesYesterday,
|
||||
] = await Promise.all([
|
||||
db.from('conversations').select('id', { count: 'exact', head: true }).eq('status', 'open'),
|
||||
db
|
||||
.from('conversations')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('status', 'open')
|
||||
.gte('created_at', todayStart),
|
||||
db
|
||||
.from('conversations')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('status', 'open')
|
||||
.gte('created_at', yesterdayStart)
|
||||
.lt('created_at', todayStart),
|
||||
db.from('contacts').select('id', { count: 'exact', head: true }).gte('created_at', todayStart),
|
||||
db
|
||||
.from('contacts')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.gte('created_at', yesterdayStart)
|
||||
.lt('created_at', todayStart),
|
||||
db.from('deals').select('value, status').eq('status', 'open'),
|
||||
db
|
||||
.from('messages')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('sender_type', 'agent')
|
||||
.gte('created_at', todayStart),
|
||||
db
|
||||
.from('messages')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('sender_type', 'agent')
|
||||
.gte('created_at', yesterdayStart)
|
||||
.lt('created_at', todayStart),
|
||||
])
|
||||
|
||||
const openDealsRows = (openDeals.data ?? []) as { value: number | null }[]
|
||||
const openDealsValue = openDealsRows.reduce((sum, d) => sum + (d.value ?? 0), 0)
|
||||
|
||||
return {
|
||||
activeConversations: {
|
||||
current: openConvCur.count ?? 0,
|
||||
// "vs yesterday" on a current-state count has no clean answer
|
||||
// without snapshots — we show the delta in NEW open conversations
|
||||
// today vs yesterday. That's the business-meaningful daily signal.
|
||||
previous: (newConvToday.count ?? 0) - (newConvYesterday.count ?? 0),
|
||||
},
|
||||
newContactsToday: {
|
||||
current: newContactsToday.count ?? 0,
|
||||
previous: newContactsYesterday.count ?? 0,
|
||||
},
|
||||
openDealsValue,
|
||||
openDealsCount: openDealsRows.length,
|
||||
messagesSentToday: {
|
||||
current: messagesToday.count ?? 0,
|
||||
previous: messagesYesterday.count ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Conversations over time ---------------------------------------
|
||||
|
||||
export async function loadConversationsSeries(
|
||||
db: DB,
|
||||
rangeDays: number,
|
||||
): Promise<ConversationsSeriesPoint[]> {
|
||||
const start = daysAgoStart(rangeDays - 1).toISOString()
|
||||
const { data, error } = await db
|
||||
.from('messages')
|
||||
.select('created_at, sender_type')
|
||||
.gte('created_at', start)
|
||||
.order('created_at', { ascending: true })
|
||||
if (error) throw error
|
||||
|
||||
const keys = lastNDayKeys(rangeDays)
|
||||
const buckets = new Map<string, { incoming: number; outgoing: number }>()
|
||||
for (const k of keys) buckets.set(k, { incoming: 0, outgoing: 0 })
|
||||
|
||||
for (const row of (data ?? []) as { created_at: string; sender_type: string }[]) {
|
||||
const key = localDayKey(row.created_at)
|
||||
const bucket = buckets.get(key)
|
||||
if (!bucket) continue
|
||||
if (row.sender_type === 'customer') bucket.incoming += 1
|
||||
else bucket.outgoing += 1 // agent + bot both count as outgoing
|
||||
}
|
||||
|
||||
return keys.map((day) => ({ day, ...(buckets.get(day) ?? { incoming: 0, outgoing: 0 }) }))
|
||||
}
|
||||
|
||||
// --- 3. Pipeline donut -------------------------------------------------
|
||||
|
||||
export async function loadPipelineDonut(db: DB): Promise<PipelineDonutData> {
|
||||
const [stagesRes, dealsRes] = await Promise.all([
|
||||
db.from('pipeline_stages').select('id, name, color, pipeline_id, position').order('position'),
|
||||
db.from('deals').select('stage_id, value, status').eq('status', 'open'),
|
||||
])
|
||||
|
||||
const stages =
|
||||
(stagesRes.data ?? []) as { id: string; name: string; color: string }[]
|
||||
const deals = (dealsRes.data ?? []) as { stage_id: string; value: number | null }[]
|
||||
|
||||
const byStage = new Map<string, { count: number; total: number }>()
|
||||
for (const d of deals) {
|
||||
const row = byStage.get(d.stage_id) ?? { count: 0, total: 0 }
|
||||
row.count += 1
|
||||
row.total += d.value ?? 0
|
||||
byStage.set(d.stage_id, row)
|
||||
}
|
||||
|
||||
const slices: PipelineStageSlice[] = stages
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
color: s.color || '#64748b',
|
||||
dealCount: byStage.get(s.id)?.count ?? 0,
|
||||
totalValue: byStage.get(s.id)?.total ?? 0,
|
||||
}))
|
||||
// Hide empty stages from the ring (but we'd still show them in the
|
||||
// legend if the user wanted a full breakdown — trimming keeps the
|
||||
// visual clean for the common case).
|
||||
.filter((s) => s.totalValue > 0 || s.dealCount > 0)
|
||||
|
||||
return {
|
||||
stages: slices,
|
||||
totalValue: slices.reduce((sum, s) => sum + s.totalValue, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Response time by day of week ----------------------------------
|
||||
|
||||
export async function loadResponseTime(db: DB): Promise<ResponseTimeSummary> {
|
||||
// Pull the last 14 days of messages in one shot, then walk per
|
||||
// conversation to find each "first inbound" → "first subsequent
|
||||
// outbound" pair. 14 days gives us both "this week" + "last week"
|
||||
// with enough overlap if the user opens the dashboard late on a
|
||||
// Monday.
|
||||
const fourteenDaysAgo = daysAgoStart(13).toISOString()
|
||||
const { data, error } = await db
|
||||
.from('messages')
|
||||
.select('conversation_id, sender_type, created_at')
|
||||
.gte('created_at', fourteenDaysAgo)
|
||||
.order('conversation_id', { ascending: true })
|
||||
.order('created_at', { ascending: true })
|
||||
if (error) throw error
|
||||
|
||||
const rows = (data ?? []) as {
|
||||
conversation_id: string
|
||||
sender_type: string
|
||||
created_at: string
|
||||
}[]
|
||||
|
||||
// Group per conversation, pair unreplied customer messages with the
|
||||
// next outbound message from the agent/bot. A single customer message
|
||||
// can only count once (avoids inflating averages if the customer
|
||||
// double-messages while the agent takes time to reply).
|
||||
interface Sample {
|
||||
customerAt: Date
|
||||
responseAt: Date
|
||||
}
|
||||
const samples: Sample[] = []
|
||||
|
||||
let currentConv = ''
|
||||
let pendingCustomer: Date | null = null
|
||||
for (const row of rows) {
|
||||
if (row.conversation_id !== currentConv) {
|
||||
currentConv = row.conversation_id
|
||||
pendingCustomer = null
|
||||
}
|
||||
const ts = new Date(row.created_at)
|
||||
if (row.sender_type === 'customer') {
|
||||
if (!pendingCustomer) pendingCustomer = ts
|
||||
} else if (pendingCustomer) {
|
||||
samples.push({ customerAt: pendingCustomer, responseAt: ts })
|
||||
pendingCustomer = null
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const thisWeekStart = daysAgoStart(mondayIndex(now))
|
||||
const lastWeekStart = daysAgoStart(mondayIndex(now) + 7)
|
||||
|
||||
// Per-day-of-week buckets, averaged over both weeks' worth of data
|
||||
// so each bar has more samples to stand on. If a day has no samples
|
||||
// its avgMinutes stays null and the chart renders the bar muted.
|
||||
const byDow = new Map<number, number[]>()
|
||||
for (let i = 0; i < 7; i++) byDow.set(i, [])
|
||||
const thisWeekMins: number[] = []
|
||||
const lastWeekMins: number[] = []
|
||||
|
||||
for (const s of samples) {
|
||||
const diffMin = (s.responseAt.getTime() - s.customerAt.getTime()) / 60_000
|
||||
if (diffMin < 0) continue
|
||||
const dow = mondayIndex(s.customerAt)
|
||||
byDow.get(dow)!.push(diffMin)
|
||||
if (s.customerAt >= thisWeekStart) {
|
||||
thisWeekMins.push(diffMin)
|
||||
} else if (s.customerAt >= lastWeekStart && s.customerAt < thisWeekStart) {
|
||||
lastWeekMins.push(diffMin)
|
||||
}
|
||||
}
|
||||
|
||||
const avg = (arr: number[]) =>
|
||||
arr.length === 0 ? null : arr.reduce((a, b) => a + b, 0) / arr.length
|
||||
|
||||
const buckets: ResponseTimeBucket[] = Array.from({ length: 7 }, (_, dow) => {
|
||||
const samples = byDow.get(dow) ?? []
|
||||
return {
|
||||
dow,
|
||||
avgMinutes: avg(samples),
|
||||
samples: samples.length,
|
||||
}
|
||||
})
|
||||
|
||||
// Silence unused-label warnings — keep the arrays explicitly named
|
||||
// for readability above.
|
||||
void DOW_SHORT_MON_FIRST
|
||||
|
||||
return {
|
||||
buckets,
|
||||
thisWeekAvg: avg(thisWeekMins),
|
||||
lastWeekAvg: avg(lastWeekMins),
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5. Activity feed --------------------------------------------------
|
||||
|
||||
export async function loadActivity(db: DB, limit = 20): Promise<ActivityItem[]> {
|
||||
// Pull ~10 from each source (plenty of headroom after merge-sort),
|
||||
// then interleave by timestamp. The individual per-table limits
|
||||
// keep the payload small; the final limit is enforced after sort.
|
||||
const [msgs, contacts, deals, broadcasts, autoLogs] = await Promise.all([
|
||||
db
|
||||
.from('messages')
|
||||
.select('id, content_text, sender_type, created_at, conversation_id, conversations(contact_id, contacts(name, phone))')
|
||||
.eq('sender_type', 'customer')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10),
|
||||
db
|
||||
.from('contacts')
|
||||
.select('id, name, phone, created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10),
|
||||
db
|
||||
.from('deals')
|
||||
.select('id, title, updated_at, stage:pipeline_stages(name)')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(10),
|
||||
db
|
||||
.from('broadcasts')
|
||||
.select('id, name, status, total_recipients, created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5),
|
||||
db
|
||||
.from('automation_logs')
|
||||
.select('id, trigger_event, status, created_at, automation:automations(name), contact:contacts(name, phone)')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10),
|
||||
])
|
||||
|
||||
const items: ActivityItem[] = []
|
||||
|
||||
// PostgREST returns nested selections as arrays by default, even when
|
||||
// the foreign key is 1:1. We normalise by taking [0] on each level.
|
||||
for (const m of (msgs.data ?? []) as unknown as Array<{
|
||||
id: string
|
||||
content_text: string | null
|
||||
created_at: string
|
||||
conversation_id: string
|
||||
conversations:
|
||||
| { contact_id: string | null; contacts: { name: string | null; phone: string }[] | { name: string | null; phone: string } | null }[]
|
||||
| { contact_id: string | null; contacts: { name: string | null; phone: string }[] | { name: string | null; phone: string } | null }
|
||||
| null
|
||||
}>) {
|
||||
const conv = Array.isArray(m.conversations) ? m.conversations[0] : m.conversations
|
||||
const contact = Array.isArray(conv?.contacts) ? conv?.contacts[0] : conv?.contacts
|
||||
const who = contact?.name || contact?.phone || 'Unknown'
|
||||
items.push({
|
||||
id: `msg-${m.id}`,
|
||||
kind: 'message',
|
||||
text: `New message from ${who}`,
|
||||
at: m.created_at,
|
||||
href: `/inbox?c=${m.conversation_id}`,
|
||||
})
|
||||
}
|
||||
|
||||
for (const c of (contacts.data ?? []) as Array<{ id: string; name: string | null; phone: string; created_at: string }>) {
|
||||
items.push({
|
||||
id: `contact-${c.id}`,
|
||||
kind: 'contact',
|
||||
text: `New contact: ${c.name || c.phone}`,
|
||||
at: c.created_at,
|
||||
href: '/contacts',
|
||||
})
|
||||
}
|
||||
|
||||
for (const d of (deals.data ?? []) as unknown as Array<{
|
||||
id: string
|
||||
title: string
|
||||
updated_at: string
|
||||
stage: { name: string }[] | { name: string } | null
|
||||
}>) {
|
||||
const stage = Array.isArray(d.stage) ? d.stage[0] : d.stage
|
||||
items.push({
|
||||
id: `deal-${d.id}`,
|
||||
kind: 'deal',
|
||||
text: stage?.name
|
||||
? `Deal "${d.title}" in ${stage.name}`
|
||||
: `Deal "${d.title}" updated`,
|
||||
at: d.updated_at,
|
||||
href: '/pipelines',
|
||||
})
|
||||
}
|
||||
|
||||
for (const b of (broadcasts.data ?? []) as Array<{
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
total_recipients: number
|
||||
created_at: string
|
||||
}>) {
|
||||
const label =
|
||||
b.status === 'sent'
|
||||
? `sent to ${b.total_recipients} contacts`
|
||||
: `${b.status} (${b.total_recipients} recipients)`
|
||||
items.push({
|
||||
id: `broadcast-${b.id}`,
|
||||
kind: 'broadcast',
|
||||
text: `Broadcast "${b.name}" ${label}`,
|
||||
at: b.created_at,
|
||||
href: '/broadcasts',
|
||||
})
|
||||
}
|
||||
|
||||
for (const l of (autoLogs.data ?? []) as unknown as Array<{
|
||||
id: string
|
||||
trigger_event: string
|
||||
status: string
|
||||
created_at: string
|
||||
automation: { name: string }[] | { name: string } | null
|
||||
contact: { name: string | null; phone: string }[] | { name: string | null; phone: string } | null
|
||||
}>) {
|
||||
const automation = Array.isArray(l.automation) ? l.automation[0] : l.automation
|
||||
const contact = Array.isArray(l.contact) ? l.contact[0] : l.contact
|
||||
const who = contact?.name || contact?.phone || 'a contact'
|
||||
const autoName = automation?.name || 'Automation'
|
||||
items.push({
|
||||
id: `auto-${l.id}`,
|
||||
kind: 'automation',
|
||||
text: `Automation "${autoName}" ${l.status === 'failed' ? 'failed for' : 'triggered for'} ${who}`,
|
||||
at: l.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
.sort((a, b) => (a.at > b.at ? -1 : a.at < b.at ? 1 : 0))
|
||||
.slice(0, limit)
|
||||
}
|
||||
67
src/lib/dashboard/types.ts
Normal file
67
src/lib/dashboard/types.ts
Normal file
@ -0,0 +1,67 @@
|
||||
// Shared result shapes the dashboard components consume. Centralised
|
||||
// here so each component stays thin and the page-level loader wires
|
||||
// them up without type gymnastics.
|
||||
|
||||
export interface MetricDelta {
|
||||
current: number
|
||||
previous: number
|
||||
}
|
||||
|
||||
export interface MetricsBundle {
|
||||
activeConversations: MetricDelta
|
||||
newContactsToday: MetricDelta
|
||||
openDealsValue: number
|
||||
openDealsCount: number
|
||||
messagesSentToday: MetricDelta
|
||||
}
|
||||
|
||||
export interface ConversationsSeriesPoint {
|
||||
day: string // YYYY-MM-DD local
|
||||
incoming: number
|
||||
outgoing: number
|
||||
}
|
||||
|
||||
export interface PipelineStageSlice {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
dealCount: number
|
||||
totalValue: number
|
||||
}
|
||||
|
||||
export interface PipelineDonutData {
|
||||
stages: PipelineStageSlice[]
|
||||
totalValue: number
|
||||
}
|
||||
|
||||
export interface ResponseTimeBucket {
|
||||
/** 0 = Mon … 6 = Sun (Monday-first). */
|
||||
dow: number
|
||||
/** Average first-response time in minutes. Null means no samples. */
|
||||
avgMinutes: number | null
|
||||
samples: number
|
||||
}
|
||||
|
||||
export interface ResponseTimeSummary {
|
||||
buckets: ResponseTimeBucket[]
|
||||
thisWeekAvg: number | null
|
||||
lastWeekAvg: number | null
|
||||
}
|
||||
|
||||
export type ActivityKind =
|
||||
| 'message'
|
||||
| 'deal'
|
||||
| 'broadcast'
|
||||
| 'automation'
|
||||
| 'contact'
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string
|
||||
kind: ActivityKind
|
||||
/** Primary line of text rendered in the feed. Pre-formatted. */
|
||||
text: string
|
||||
/** ISO timestamp the item happened at, drives relative-time + sort. */
|
||||
at: string
|
||||
/** Optional deep-link for the whole row (not all items have a target). */
|
||||
href?: string
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user