wacrm/src/components/dashboard/response-time-chart.tsx
Arnas Donauskas ed669424b6
feat(dashboard): replace placeholder with real analytics
Rewrites the dashboard page as a data-driven analytics surface using
existing Supabase tables only — no schema changes, no other pages
touched.

Widgets (all render independent skeletons while their data fetches,
and show a shared empty state when there's not enough data to be
useful):

- Metric cards (Active Conversations, New Contacts Today, Open Deals
  Value, Messages Sent Today) with up/down/no-change delta indicator.
- Quick actions row (New Contact / Deal / Broadcast / Automation).
- Conversations Over Time: SVG line chart, 7/30/90-day tabs, per-day
  hover crosshair + tooltip. Cached per range so tab switches are
  instant.
- Pipeline Value: SVG donut with per-stage arc segments, total in the
  center, legend with deal count + value.
- Average First Response Time: SVG bar chart by weekday with a
  configurable dashed target line and this-week / last-week summary.
- Activity feed: latest 20 items merged across messages, contacts,
  deals, broadcasts, and automation logs — click-through deep-links
  where applicable.

Deviations from spec, noted up-front:
- Strictly dark theme — spec's #f9fafb alternating rows translate to
  slate-900 / slate-900/40 stripes.
- Kept Inter (tabular-nums, bold 28px) for metric values instead of
  Space Grotesk to avoid touching root layout.
- Quick actions link to the relevant list pages (/contacts, etc.)
  rather than auto-opening their create modals — that would require
  modifying those pages.

All aggregation is client-side via RLS-scoped queries. If a tenant's
dataset outgrows a few thousand messages per range window, the
response-time and line-chart queries should migrate to SQL RPCs —
noted as follow-up, not shipped here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:16:00 +03:00

203 lines
5.9 KiB
TypeScript

"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&apos;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
}