mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
- Added `Dashboard` namespace to English dictionary. - Translated metric cards, quick actions, and activity feed. - Localized charts (conversations, pipeline donut, response time) and empty states.
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
"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 }
|
|
|
|
import { useTranslations } from 'next-intl'
|
|
|
|
export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) {
|
|
const t = useTranslations('Dashboard.conversationsChart')
|
|
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="flex h-full flex-col rounded-xl border border-border bg-card">
|
|
<header className="flex items-center justify-between border-b border-border px-5 py-4">
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-foreground">{t('title')}</h2>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">{t('description')}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 rounded-lg bg-muted/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-secondary text-secondary-foreground'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{t('days', { count: r })}
|
|
</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={t('noActivity')}
|
|
hint={t('noActivityHint')}
|
|
/>
|
|
) : (
|
|
<LineSvg data={data} maxY={maxY} ticks={niceTicks} t={t} />
|
|
)}
|
|
</div>
|
|
|
|
<footer className="flex items-center gap-4 border-t border-border px-5 py-3 text-xs text-muted-foreground">
|
|
<LegendDot color="#3b82f6" label={t('incoming')} />
|
|
<LegendDot color="#7c3aed" label={t('outgoing')} />
|
|
</footer>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// The actual SVG. Two polylines + per-day hit targets for hover.
|
|
// ------------------------------------------------------------
|
|
|
|
function LineSvg({
|
|
data,
|
|
maxY,
|
|
ticks,
|
|
t
|
|
}: {
|
|
data: ConversationsSeriesPoint[]
|
|
maxY: number
|
|
ticks: number[]
|
|
t: ReturnType<typeof useTranslations>
|
|
}) {
|
|
// Hover state: both the snapped index AND the tooltip's pixel
|
|
// offset inside the wrapper div. They're stored together so the
|
|
// tooltip positions against the chart's actual rendered pixels,
|
|
// not against a raw viewBox percentage. See the precision note on
|
|
// the onMove handler below.
|
|
const [hover, setHover] = useState<{ idx: number; tooltipLeftPx: number } | null>(null)
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
const wrapRef = useRef<HTMLDivElement>(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: use the SVG's current screen-CTM to map clientX
|
|
// back to viewBox coordinates. The previous rect-based math
|
|
// assumed the viewBox filled the SVG DOM box linearly, but
|
|
// `preserveAspectRatio="xMidYMid meet"` (the SVG default)
|
|
// letterboxes the content horizontally when the container is
|
|
// wider than the viewBox aspect — so hover snapped hundreds of
|
|
// pixels off on wide layouts. CTM-inverse correctly accounts for
|
|
// letterboxing, scaling, and any future transform changes.
|
|
useEffect(() => {
|
|
const svg = svgRef.current
|
|
const wrap = wrapRef.current
|
|
if (!svg || !wrap) return
|
|
const onMove = (e: MouseEvent) => {
|
|
const ctm = svg.getScreenCTM()
|
|
if (!ctm) return
|
|
const pt = svg.createSVGPoint()
|
|
pt.x = e.clientX
|
|
pt.y = e.clientY
|
|
const local = pt.matrixTransform(ctm.inverse())
|
|
const xVb = local.x
|
|
if (xVb < PADDING.left - 8 || xVb > VB_W - PADDING.right + 8) {
|
|
setHover(null)
|
|
return
|
|
}
|
|
const relative = xVb - PADDING.left
|
|
const idx = Math.max(
|
|
0,
|
|
Math.min(data.length - 1, Math.round(stepX === 0 ? 0 : relative / stepX)),
|
|
)
|
|
// Map the snapped data-point's viewBox x back to screen, then
|
|
// subtract the wrapper's left edge — that pixel offset is what
|
|
// the absolutely-positioned tooltip div consumes. `xFor` is
|
|
// inlined here so the effect deps stay stable (it's a closure
|
|
// that'd otherwise be a new reference every render).
|
|
const dataPointVbX = PADDING.left + idx * stepX
|
|
const dataPointPt = svg.createSVGPoint()
|
|
dataPointPt.x = dataPointVbX
|
|
dataPointPt.y = 0
|
|
const screen = dataPointPt.matrixTransform(ctm)
|
|
const wrapRect = wrap.getBoundingClientRect()
|
|
setHover({ idx, tooltipLeftPx: screen.x - wrapRect.left })
|
|
}
|
|
const onLeave = () => setHover(null)
|
|
svg.addEventListener('mousemove', onMove)
|
|
svg.addEventListener('mouseleave', onLeave)
|
|
return () => {
|
|
svg.removeEventListener('mousemove', onMove)
|
|
svg.removeEventListener('mouseleave', onLeave)
|
|
}
|
|
// xFor + yFor close over stepX, so stepX covers them.
|
|
}, [data, stepX])
|
|
|
|
const hovered = hover !== null ? data[hover.idx] : null
|
|
const hoverX = hover !== null ? xFor(hover.idx) : 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 ref={wrapRef} className="relative w-full">
|
|
<svg
|
|
ref={svgRef}
|
|
viewBox={`0 0 ${VB_W} ${VB_H}`}
|
|
className="h-[240px] w-full"
|
|
role="img"
|
|
aria-label={t('ariaLabel')}
|
|
>
|
|
{/* 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="var(--border)"
|
|
strokeDasharray="3 3"
|
|
/>
|
|
<text
|
|
x={PADDING.left - 8}
|
|
y={y}
|
|
textAnchor="end"
|
|
dominantBaseline="middle"
|
|
className="fill-muted-foreground 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-muted-foreground text-[10px]"
|
|
>
|
|
{shortDayLabel(p.day)}
|
|
</text>
|
|
) : null,
|
|
)}
|
|
|
|
{/* Outgoing polyline (violet) */}
|
|
<path
|
|
d={outgoingPath}
|
|
fill="none"
|
|
stroke="#7c3aed"
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
{/* Incoming polyline (blue) */}
|
|
<path
|
|
d={incomingPath}
|
|
fill="none"
|
|
stroke="#3b82f6"
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
|
|
{/* Hover crosshair */}
|
|
{hover !== null && (
|
|
<g pointerEvents="none">
|
|
<line
|
|
x1={hoverX}
|
|
x2={hoverX}
|
|
y1={PADDING.top}
|
|
y2={PADDING.top + chartH}
|
|
stroke="var(--muted-foreground)"
|
|
strokeDasharray="3 3"
|
|
/>
|
|
<circle cx={hoverX} cy={yFor(data[hover.idx].incoming)} r={3.5} fill="#3b82f6" />
|
|
<circle cx={hoverX} cy={yFor(data[hover.idx].outgoing)} r={3.5} fill="#7c3aed" />
|
|
</g>
|
|
)}
|
|
</svg>
|
|
|
|
{/* Tooltip — absolute-positioned div so we get crisp text, not
|
|
SVG-rendered text. The left offset comes from the CTM-based
|
|
mapping so it lines up with the actual crosshair pixel, not a
|
|
letterboxed viewBox percentage. */}
|
|
{hovered && hover !== null && (
|
|
<div
|
|
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-border bg-popover px-2.5 py-1.5 text-[11px] shadow-lg"
|
|
style={{ left: `${hover.tooltipLeftPx}px` }}
|
|
>
|
|
<div className="font-medium text-popover-foreground">{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" />
|
|
{t('tooltipIncoming', { count: hovered.incoming })}
|
|
</span>
|
|
<span className="flex items-center gap-1.5 text-primary">
|
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
|
|
{t('tooltipOutgoing', { count: hovered.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
|
|
}
|