From ed669424b608f944316ef7ed58d4df6d26383856 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 17 Apr 2026 22:16:00 +0300 Subject: [PATCH] feat(dashboard): replace placeholder with real analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/(dashboard)/dashboard/page.tsx | 582 ++++++------------ src/components/dashboard/activity-feed.tsx | 117 ++++ .../dashboard/conversations-chart.tsx | 305 +++++++++ src/components/dashboard/empty-state.tsx | 36 ++ src/components/dashboard/metric-card.tsx | 57 ++ src/components/dashboard/pipeline-donut.tsx | 144 +++++ src/components/dashboard/quick-actions.tsx | 45 ++ .../dashboard/response-time-chart.tsx | 202 ++++++ src/components/dashboard/skeleton.tsx | 25 + src/lib/dashboard/date-utils.ts | 52 ++ src/lib/dashboard/queries.ts | 398 ++++++++++++ src/lib/dashboard/types.ts | 67 ++ 12 files changed, 1634 insertions(+), 396 deletions(-) create mode 100644 src/components/dashboard/activity-feed.tsx create mode 100644 src/components/dashboard/conversations-chart.tsx create mode 100644 src/components/dashboard/empty-state.tsx create mode 100644 src/components/dashboard/metric-card.tsx create mode 100644 src/components/dashboard/pipeline-donut.tsx create mode 100644 src/components/dashboard/quick-actions.tsx create mode 100644 src/components/dashboard/response-time-chart.tsx create mode 100644 src/components/dashboard/skeleton.tsx create mode 100644 src/lib/dashboard/date-utils.ts create mode 100644 src/lib/dashboard/queries.ts create mode 100644 src/lib/dashboard/types.ts diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 24fa91d..bf68834 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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 ( -
-
-

{title}

-
- {icon} -
-
- {loading ? ( -
- ) : ( -

{value}

- )} -
- ); -} - -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(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([]); - const [pipelineOverviews, setPipelineOverviews] = useState([]); - const [recentBroadcasts, setRecentBroadcasts] = useState([]); + const [range, setRange] = useState(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>({ + 7: null, + 30: null, + 90: null, + }) + const [seriesLoading, setSeriesLoading] = useState(true) + + const [pipeline, setPipeline] = useState(null) + const [pipelineLoading, setPipelineLoading] = useState(true) + + const [responseTime, setResponseTime] = useState(null) + const [responseTimeLoading, setResponseTimeLoading] = useState(true) + + const [activity, setActivity] = useState(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 ( -
+
{/* Header */}

Dashboard

- Overview of your WhatsApp CRM activity + Live analytics across conversations, contacts, deals, broadcasts, and automations.

- {/* Row 1: Stat Cards */} + {/* Metric cards */}
- } - loading={loading} - /> - } - loading={loading} - /> - } - loading={loading} - /> - } - loading={loading} - /> + {metricsLoading || !metrics ? ( + Array.from({ length: 4 }).map((_, i) => ) + ) : ( + <> + + + + + + )}
- {/* Row 2: Recent Conversations */} -
-
-
- -

- Recent Conversations -

-
- + {/* Quick actions */} + + + {/* Charts row */} +
+
+
-
- {loading ? ( - Array.from({ length: 3 }).map((_, i) => ( -
-
-
-
-
-
-
-
- )) - ) : recentConversations.length === 0 ? ( -
- No conversations yet -
- ) : ( - recentConversations.map((conv) => ( - - )) - )} +
+
- {/* Row 3: Pipeline Overview */} -
-
- -

- Pipeline Overview -

-
-
- {loading ? ( -
- {[1, 2].map((i) => ( -
-
-
- {[1, 2, 3, 4].map((j) => ( -
- ))} -
-
- ))} -
- ) : pipelineOverviews.length === 0 ? ( -
- No pipelines created yet -
- ) : ( -
- {pipelineOverviews.map(({ pipeline, stages }) => ( -
-

- {pipeline.name} -

-
- {stages.map((stage) => ( -
-
-
- - {stage.name} - -
-

- {stage.dealCount} -

- {stage.totalValue > 0 && ( -

- - {formatCurrency(stage.totalValue)} -

- )} -
- ))} -
-
- ))} -
- )} -
-
+ {/* Response time */} + - {/* Row 4: Recent Broadcasts */} -
-
- -

- Recent Broadcasts -

-
-
- {loading ? ( - Array.from({ length: 2 }).map((_, i) => ( -
-
-
-
-
-
-
- )) - ) : recentBroadcasts.length === 0 ? ( -
- No broadcasts yet -
- ) : ( - recentBroadcasts.map((broadcast) => ( -
-
- -
-
-

- {broadcast.name} -

-
- - {broadcast.sent_count}/{broadcast.total_recipients} sent - - - - {formatRelativeTime(broadcast.created_at)} - -
-
- - {broadcast.status} - -
- )) - )} -
-
+ {/* Activity feed */} +
- ); + ) +} + +// ------------------------------------------------------------ + +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}` } diff --git a/src/components/dashboard/activity-feed.tsx b/src/components/dashboard/activity-feed.tsx new file mode 100644 index 0000000..36d3da3 --- /dev/null +++ b/src/components/dashboard/activity-feed.tsx @@ -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 = { + 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 ( +
+
+

Recent Activity

+ + View all → + +
+ + {loading || !items ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : items.length === 0 ? ( +
+ +
+ ) : ( +
    + {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 = ( +
    + + + + + {it.text} + + + {relativeTime(it.at)} + +
    + ) + return ( +
  • + {it.href ? ( + + {row} + + ) : ( + row + )} +
  • + ) + })} +
+ )} +
+ ) +} + +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() +} diff --git a/src/components/dashboard/conversations-chart.tsx b/src/components/dashboard/conversations-chart.tsx new file mode 100644 index 0000000..458db7a --- /dev/null +++ b/src/components/dashboard/conversations-chart.tsx @@ -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 + 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 ( +
+
+
+

Conversations Over Time

+

Daily message volume by direction

+
+
+ {[7, 30, 90].map((r) => ( + + ))} +
+
+ +
+ {loading || !data ? ( + + ) : data.every((p) => p.incoming === 0 && p.outgoing === 0) ? ( + + ) : ( + + )} +
+ +
+ + +
+
+ ) +} + +// ------------------------------------------------------------ +// 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(null) + const svgRef = useRef(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 ( +
+ + {/* Y-axis gridlines + labels */} + {ticks.map((t) => { + const y = yFor(t) + return ( + + + + {t} + + + ) + })} + + {/* X-axis labels */} + {data.map((p, i) => + i % labelStride === 0 ? ( + + {shortDayLabel(p.day)} + + ) : null, + )} + + {/* Outgoing polyline (emerald) */} + + {/* Incoming polyline (blue) */} + + + {/* Hover crosshair */} + {hoverIdx !== null && ( + + + + + + )} + + + {/* Tooltip — absolute-positioned div so we get crisp text, not SVG */} + {hovered && hoverIdx !== null && ( +
+
{longDayLabel(hovered.day)}
+
+ + + {hovered.incoming} incoming + + + + {hovered.outgoing} outgoing + +
+
+ )} +
+ ) +} + +function LegendDot({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ) +} + +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 +} diff --git a/src/components/dashboard/empty-state.tsx b/src/components/dashboard/empty-state.tsx new file mode 100644 index 0000000..bcdcce5 --- /dev/null +++ b/src/components/dashboard/empty-state.tsx @@ -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 ( +
+
+ +
+

{title}

+ {hint &&

{hint}

} +
+ ) +} diff --git a/src/components/dashboard/metric-card.tsx b/src/components/dashboard/metric-card.tsx new file mode 100644 index 0000000..348b2b9 --- /dev/null +++ b/src/components/dashboard/metric-card.tsx @@ -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 ( +
+
+

{title}

+
+ +
+
+

+ {value} +

+ {delta ? : subtitle ? ( +

{subtitle}

+ ) : null} +
+ ) +} + +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 ( +
+ + {label} +
+ ) +} diff --git a/src/components/dashboard/pipeline-donut.tsx b/src/components/dashboard/pipeline-donut.tsx new file mode 100644 index 0000000..4a9854a --- /dev/null +++ b/src/components/dashboard/pipeline-donut.tsx @@ -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 ( +
+
+

Pipeline Value

+

+ Open deals by stage +

+
+ +
+ {loading || !data ? ( + + ) : data.stages.length === 0 ? ( + + ) : ( + <> + +
    + {data.stages.map((s) => ( +
  • + + {s.name} + + {s.dealCount} deal{s.dealCount === 1 ? '' : 's'} + + + {formatCurrencyShort(s.totalValue)} + +
  • + ))} +
+ + )} +
+
+ ) +} + +// ------------------------------------------------------------ +// SVG ring. 200×200 viewBox, 12px ring width. We draw one +// 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 ( +
+ + {/* background ring */} + + {segments.map((seg) => ( + + ))} + {/* center label */} + + Total + + + {formatCurrencyShort(data.totalValue)} + + +
+ ) +} + +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)}` +} diff --git a/src/components/dashboard/quick-actions.tsx b/src/components/dashboard/quick-actions.tsx new file mode 100644 index 0000000..c37711e --- /dev/null +++ b/src/components/dashboard/quick-actions.tsx @@ -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 ( +
+ {ACTIONS.map((a) => { + const Icon = a.icon + return ( + +
+ +
+ {a.label} + + ) + })} +
+ ) +} diff --git a/src/components/dashboard/response-time-chart.tsx b/src/components/dashboard/response-time-chart.tsx new file mode 100644 index 0000000..d368199 --- /dev/null +++ b/src/components/dashboard/response-time-chart.tsx @@ -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 ( +
+
+
+

+ Average First Response Time +

+

+ Minutes to reply to a customer's first unreplied message, by + weekday +

+
+ {data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && ( +
+
+ This week:{' '} + + {fmt(data.thisWeekAvg)} + +
+
+ Last week:{' '} + {fmt(data.lastWeekAvg)} +
+
+ )} +
+ +
+ {loading || !data ? ( + + ) : !hasData ? ( + + ) : ( + + )} +
+
+ ) +} + +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 ( + + {/* Y grid */} + {ticks.map((t) => { + const y = yFor(t) + return ( + + + + {t}m + + + ) + })} + + {/* 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 && ( + + + + target {thresholdMinutes}m + + + )} + + {/* 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 ( + + + + {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'})` : ''} + + + + {DOW_SHORT_MON_FIRST[i]} + + + ) + })} + + ) +} + +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 +} diff --git a/src/components/dashboard/skeleton.tsx b/src/components/dashboard/skeleton.tsx new file mode 100644 index 0000000..cd1b469 --- /dev/null +++ b/src/components/dashboard/skeleton.tsx @@ -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
+} + +export function SkeletonCard({ className }: { className?: string }) { + return ( +
+ + + +
+ ) +} diff --git a/src/lib/dashboard/date-utils.ts b/src/lib/dashboard/date-utils.ts new file mode 100644 index 0000000..10dce41 --- /dev/null +++ b/src/lib/dashboard/date-utils.ts @@ -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 diff --git a/src/lib/dashboard/queries.ts b/src/lib/dashboard/queries.ts new file mode 100644 index 0000000..ac03a2d --- /dev/null +++ b/src/lib/dashboard/queries.ts @@ -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 { + 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 { + 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() + 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 { + 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() + 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 { + // 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() + 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 { + // 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) +} diff --git a/src/lib/dashboard/types.ts b/src/lib/dashboard/types.ts new file mode 100644 index 0000000..790d6e8 --- /dev/null +++ b/src/lib/dashboard/types.ts @@ -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 +}