mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +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 { useCallback, useEffect, useState } from 'react'
|
||||||
import { useRouter } from "next/navigation";
|
import { createClient } from '@/lib/supabase/client'
|
||||||
import { createClient } from "@/lib/supabase/client";
|
|
||||||
import { Conversation, Pipeline, PipelineStage, Broadcast } from "@/types";
|
|
||||||
import {
|
import {
|
||||||
Users,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Mail,
|
UserPlus,
|
||||||
TrendingUp,
|
|
||||||
ArrowRight,
|
|
||||||
Radio,
|
|
||||||
GitBranch,
|
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Clock,
|
Send,
|
||||||
} from "lucide-react";
|
} from 'lucide-react'
|
||||||
|
|
||||||
function formatCurrency(value: number) {
|
import {
|
||||||
return new Intl.NumberFormat("en-US", {
|
loadActivity,
|
||||||
style: "currency",
|
loadConversationsSeries,
|
||||||
currency: "USD",
|
loadMetrics,
|
||||||
minimumFractionDigits: 0,
|
loadPipelineDonut,
|
||||||
maximumFractionDigits: 0,
|
loadResponseTime,
|
||||||
}).format(value);
|
} from '@/lib/dashboard/queries'
|
||||||
}
|
import type {
|
||||||
|
ActivityItem,
|
||||||
|
ConversationsSeriesPoint,
|
||||||
|
MetricsBundle,
|
||||||
|
PipelineDonutData,
|
||||||
|
ResponseTimeSummary,
|
||||||
|
} from '@/lib/dashboard/types'
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string) {
|
import { MetricCard } from '@/components/dashboard/metric-card'
|
||||||
const date = new Date(dateStr);
|
import { SkeletonCard } from '@/components/dashboard/skeleton'
|
||||||
const now = new Date();
|
import { QuickActions } from '@/components/dashboard/quick-actions'
|
||||||
const diffMs = now.getTime() - date.getTime();
|
import { ConversationsChart } from '@/components/dashboard/conversations-chart'
|
||||||
const diffMin = Math.floor(diffMs / 60000);
|
import { PipelineDonut } from '@/components/dashboard/pipeline-donut'
|
||||||
const diffHr = Math.floor(diffMin / 60);
|
import { ResponseTimeChart } from '@/components/dashboard/response-time-chart'
|
||||||
const diffDay = Math.floor(diffHr / 24);
|
import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
||||||
|
|
||||||
if (diffMin < 1) return "Just now";
|
type RangeDays = 7 | 30 | 90
|
||||||
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 })[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
|
||||||
const supabase = createClient();
|
const [metricsLoading, setMetricsLoading] = useState(true)
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [range, setRange] = useState<RangeDays>(30)
|
||||||
const [totalContacts, setTotalContacts] = useState(0);
|
// Keep a cache per range so switching tabs doesn't re-fetch what we
|
||||||
const [openConversations, setOpenConversations] = useState(0);
|
// already have. Ranges the user hasn't opened yet stay null and
|
||||||
const [messagesToday, setMessagesToday] = useState(0);
|
// trigger a fetch on first view.
|
||||||
const [activeDeals, setActiveDeals] = useState(0);
|
const [series, setSeries] = useState<Record<RangeDays, ConversationsSeriesPoint[] | null>>({
|
||||||
const [recentConversations, setRecentConversations] = useState<Conversation[]>([]);
|
7: null,
|
||||||
const [pipelineOverviews, setPipelineOverviews] = useState<PipelineOverviewData[]>([]);
|
30: null,
|
||||||
const [recentBroadcasts, setRecentBroadcasts] = useState<Broadcast[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
loadDashboardData();
|
loadAll()
|
||||||
}, []);
|
}, [loadAll])
|
||||||
|
|
||||||
async function loadDashboardData() {
|
// Range switch handler — kept in an event callback (not an effect)
|
||||||
setLoading(true);
|
// 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
|
||||||
const todayStart = new Date();
|
// previously-viewed range is instant and doesn't re-fetch.
|
||||||
todayStart.setHours(0, 0, 0, 0);
|
const handleRangeChange = useCallback(
|
||||||
|
(r: RangeDays) => {
|
||||||
const [
|
setRange(r)
|
||||||
contactsRes,
|
if (series[r] !== null) return
|
||||||
openConvRes,
|
setSeriesLoading(true)
|
||||||
messagesTodayRes,
|
const db = createClient()
|
||||||
dealsRes,
|
loadConversationsSeries(db, r)
|
||||||
recentConvRes,
|
.then((s) => setSeries((prev) => ({ ...prev, [r]: s })))
|
||||||
pipelinesRes,
|
.catch((err) => console.error('[dashboard] series failed:', err))
|
||||||
broadcastsRes,
|
.finally(() => setSeriesLoading(false))
|
||||||
] = await Promise.all([
|
},
|
||||||
supabase.from("contacts").select("id", { count: "exact", head: true }),
|
[series],
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 1: Stat Cards */}
|
{/* Metric cards */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
{metricsLoading || !metrics ? (
|
||||||
title="Total Contacts"
|
Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
|
||||||
value={totalContacts.toLocaleString()}
|
) : (
|
||||||
icon={<Users className="h-5 w-5" />}
|
<>
|
||||||
loading={loading}
|
<MetricCard
|
||||||
/>
|
title="Active Conversations"
|
||||||
<StatCard
|
value={metrics.activeConversations.current.toLocaleString()}
|
||||||
title="Open Conversations"
|
icon={MessageSquare}
|
||||||
value={openConversations.toLocaleString()}
|
delta={{
|
||||||
icon={<MessageSquare className="h-5 w-5" />}
|
sign: metrics.activeConversations.previous,
|
||||||
loading={loading}
|
label: deltaLabel(metrics.activeConversations.previous, 'new today vs yesterday'),
|
||||||
/>
|
}}
|
||||||
<StatCard
|
/>
|
||||||
title="Messages Today"
|
<MetricCard
|
||||||
value={messagesToday.toLocaleString()}
|
title="New Contacts Today"
|
||||||
icon={<Mail className="h-5 w-5" />}
|
value={metrics.newContactsToday.current.toLocaleString()}
|
||||||
loading={loading}
|
icon={UserPlus}
|
||||||
/>
|
delta={{
|
||||||
<StatCard
|
sign:
|
||||||
title="Active Deals"
|
metrics.newContactsToday.current - metrics.newContactsToday.previous,
|
||||||
value={activeDeals.toLocaleString()}
|
label: deltaLabel(
|
||||||
icon={<TrendingUp className="h-5 w-5" />}
|
metrics.newContactsToday.current - metrics.newContactsToday.previous,
|
||||||
loading={loading}
|
'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>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Recent Conversations */}
|
{/* Quick actions */}
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
<QuickActions />
|
||||||
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Charts row */}
|
||||||
<MessageSquare className="h-4 w-4 text-emerald-500" />
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
||||||
<h2 className="text-sm font-semibold text-white">
|
<div className="lg:col-span-3">
|
||||||
Recent Conversations
|
<ConversationsChart
|
||||||
</h2>
|
series={series}
|
||||||
</div>
|
loading={seriesLoading}
|
||||||
<button
|
range={range}
|
||||||
onClick={() => router.push("/inbox")}
|
onRangeChange={handleRangeChange}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-slate-800">
|
<div className="lg:col-span-2">
|
||||||
{loading ? (
|
<PipelineDonut data={pipeline} loading={pipelineLoading} />
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3: Pipeline Overview */}
|
{/* Response time */}
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
<ResponseTimeChart data={responseTime} loading={responseTimeLoading} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Row 4: Recent Broadcasts */}
|
{/* Activity feed */}
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
<ActivityFeed items={activity} loading={activityLoading} />
|
||||||
<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>
|
|
||||||
</div>
|
</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