Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
167
wacrm/src/components/dashboard/activity-feed.tsx
Normal file
167
wacrm/src/components/dashboard/activity-feed.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
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
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [5, 10, 20, 50] as const
|
||||
type PageSize = (typeof PAGE_SIZES)[number]
|
||||
|
||||
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-primary/10 text-primary' },
|
||||
deal: { icon: Briefcase, badge: 'bg-primary/10 text-primary' },
|
||||
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) {
|
||||
// Start at 5 — a quick scan of the most recent events without
|
||||
// dominating vertical real estate. User expands explicitly via the
|
||||
// footer control when they want deeper history.
|
||||
const [pageSize, setPageSize] = useState<PageSize>(5)
|
||||
|
||||
const totalLoaded = items?.length ?? 0
|
||||
const visible = items?.slice(0, pageSize) ?? []
|
||||
// A size option is "useful" if picking it would reveal rows the
|
||||
// smaller option doesn't already show. With PAGE_SIZES=[5,10,20,50]:
|
||||
// "10" is useful only once we've loaded ≥6 items, "20" once ≥11, etc.
|
||||
// The smallest option is always enabled.
|
||||
const isSizeUseful = (size: PageSize, i: number) =>
|
||||
i === 0 || totalLoaded > PAGE_SIZES[i - 1]
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<header className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">Recent Activity</h2>
|
||||
<Link
|
||||
href="/inbox"
|
||||
className="text-xs font-medium text-primary hover:text-primary/80"
|
||||
>
|
||||
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-border">
|
||||
{visible.map((it, i) => {
|
||||
const theme = KIND_THEME[it.kind]
|
||||
const Icon = theme.icon
|
||||
// Alternating row background for scanability. bg-muted/40
|
||||
// keeps the stripe visible in both light and dark modes
|
||||
// (bg-card/40 vanishes against a white card surface in light).
|
||||
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-muted/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-foreground">
|
||||
{it.text}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{relativeTime(it.at)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<li key={it.id} className={cn(stripe, 'transition-colors hover:bg-muted/40')}>
|
||||
{it.href ? (
|
||||
<Link href={it.href} className="block">
|
||||
{row}
|
||||
</Link>
|
||||
) : (
|
||||
row
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs">
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
Showing {visible.length} of {totalLoaded}
|
||||
{totalLoaded === 50 ? '+' : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="mr-1 text-muted-foreground">Show</span>
|
||||
{PAGE_SIZES.map((size, i) => {
|
||||
const disabled = !isSizeUseful(size, i)
|
||||
return (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() => setPageSize(size)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 font-medium tabular-nums transition-colors',
|
||||
pageSize === size
|
||||
? 'bg-secondary text-secondary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</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()
|
||||
}
|
||||
341
wacrm/src/components/dashboard/conversations-chart.tsx
Normal file
341
wacrm/src/components/dashboard/conversations-chart.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
"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="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">Conversations Over Time</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">Daily message volume by direction</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',
|
||||
)}
|
||||
>
|
||||
{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-border px-5 py-3 text-xs text-muted-foreground">
|
||||
<LegendDot color="#3b82f6" label="Incoming" />
|
||||
<LegendDot color="#7c3aed" 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[]
|
||||
}) {
|
||||
// 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="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="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" />
|
||||
{hovered.incoming} 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" />
|
||||
{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
wacrm/src/components/dashboard/empty-state.tsx
Normal file
36
wacrm/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-border bg-card/40 px-4 py-6 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
{hint && <p className="max-w-xs text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
wacrm/src/components/dashboard/metric-card.tsx
Normal file
57
wacrm/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-border bg-card p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-[28px] leading-none font-bold tabular-nums text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
{delta ? <DeltaRow sign={delta.sign} label={delta.label} /> : subtitle ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeltaRow({ sign, label }: { sign: number; label: string }) {
|
||||
const tone =
|
||||
sign > 0
|
||||
? 'text-primary'
|
||||
: sign < 0
|
||||
? 'text-red-400'
|
||||
: 'text-muted-foreground'
|
||||
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>
|
||||
)
|
||||
}
|
||||
141
wacrm/src/components/dashboard/pipeline-donut.tsx
Normal file
141
wacrm/src/components/dashboard/pipeline-donut.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import { GitBranch } from 'lucide-react'
|
||||
import type { PipelineDonutData } from '@/lib/dashboard/types'
|
||||
import { formatCurrencyShort } from '@/lib/currency'
|
||||
import { EmptyState } from './empty-state'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
interface PipelineDonutProps {
|
||||
data: PipelineDonutData | null
|
||||
loading: boolean
|
||||
/** Account default currency for the totals. */
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
||||
return (
|
||||
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
|
||||
<header className="border-b border-border px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">Pipeline Value</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
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} currency={currency} />
|
||||
<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-muted-foreground">{s.name}</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span className="w-20 text-right text-muted-foreground tabular-nums">
|
||||
{formatCurrencyShort(s.totalValue, currency)}
|
||||
</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, currency }: { data: PipelineDonutData; currency: string }) {
|
||||
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="var(--muted)" 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-muted-foreground text-[11px]"
|
||||
>
|
||||
Total
|
||||
</text>
|
||||
<text
|
||||
x={cx}
|
||||
y={cy + 14}
|
||||
textAnchor="middle"
|
||||
className="fill-foreground text-[18px] font-semibold tabular-nums"
|
||||
>
|
||||
{formatCurrencyShort(data.totalValue, currency)}
|
||||
</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}`
|
||||
}
|
||||
45
wacrm/src/components/dashboard/quick-actions.tsx
Normal file
45
wacrm/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-primary' },
|
||||
{ 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-primary' },
|
||||
]
|
||||
|
||||
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-border bg-card px-4 py-3 transition-colors hover:border-border hover:bg-muted/60"
|
||||
>
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-muted ${a.tint}`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{a.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
wacrm/src/components/dashboard/response-time-chart.tsx
Normal file
116
wacrm/src/components/dashboard/response-time-chart.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"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 { BarChart } from '@/components/tremor/bar-chart'
|
||||
import { EmptyState } from './empty-state'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
interface ResponseTimeChartProps {
|
||||
data: ResponseTimeSummary | null
|
||||
loading: boolean
|
||||
/** Minutes. Surfaced as a "target" pill in the header. The
|
||||
* hand-rolled SVG version drew this as a horizontal dashed
|
||||
* line on the chart; Tremor BarChart doesn't expose Recharts
|
||||
* primitives, so we promote it to the header for now. A
|
||||
* follow-up can introduce an overlay or extend the vendored
|
||||
* BarChart with a `referenceLines` prop. */
|
||||
thresholdMinutes?: number
|
||||
}
|
||||
|
||||
// Single category, single colour — the data is "average minutes
|
||||
// per weekday". Tremor expects categories as the second tuple in
|
||||
// the row object, so we shape the buckets into
|
||||
// `{ day: 'Mon', 'Avg minutes': 4.2 }` rows below.
|
||||
const CATEGORY = 'Avg minutes'
|
||||
|
||||
export function ResponseTimeChart({
|
||||
data,
|
||||
loading,
|
||||
thresholdMinutes = 5,
|
||||
}: ResponseTimeChartProps) {
|
||||
const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false
|
||||
|
||||
// Map buckets → Tremor rows. Null `avgMinutes` (no samples)
|
||||
// collapses to 0; the chart will render an empty slot for it.
|
||||
// We attach `samples` on the row so a future customTooltip can
|
||||
// surface "no samples" copy without losing the data shape.
|
||||
const chartData =
|
||||
data?.buckets.map((b, i) => ({
|
||||
day: DOW_SHORT_MON_FIRST[i],
|
||||
[CATEGORY]: b.avgMinutes ?? 0,
|
||||
samples: b.samples,
|
||||
})) ?? []
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<header className="flex items-center justify-between gap-3 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
Average First Response Time
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Minutes to reply to a customer's first unreplied message, by
|
||||
weekday
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-right text-xs">
|
||||
{thresholdMinutes > 0 && (
|
||||
<span className="rounded-full border border-rose-500/40 bg-rose-500/10 px-2 py-0.5 font-medium text-rose-300 tabular-nums">
|
||||
target {thresholdMinutes}m
|
||||
</span>
|
||||
)}
|
||||
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
|
||||
<div>
|
||||
<div className="text-muted-foreground">
|
||||
This week:{' '}
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{fmt(data.thisWeekAvg)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Last week:{' '}
|
||||
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-5">
|
||||
{loading || !data ? (
|
||||
<Skeleton className="h-[260px] w-full" />
|
||||
) : !hasData ? (
|
||||
<EmptyState
|
||||
icon={Clock}
|
||||
title="No replies recorded yet"
|
||||
hint="This chart fills in as you reply to customer messages."
|
||||
/>
|
||||
) : (
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="day"
|
||||
categories={[CATEGORY]}
|
||||
// 'violet' maps to Tailwind's `fill-violet-500` — matches
|
||||
// the brand accent the hand-rolled bars used (#7c3aed).
|
||||
colors={['violet']}
|
||||
valueFormatter={(value) => `${value.toFixed(1)}m`}
|
||||
showLegend={false}
|
||||
yAxisWidth={48}
|
||||
// Compact height so the chart sits well inside the card
|
||||
// without dominating the row alongside the donut + activity feed.
|
||||
className="h-[260px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
25
wacrm/src/components/dashboard/skeleton.tsx
Normal file
25
wacrm/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-muted', className)} />
|
||||
}
|
||||
|
||||
export function SkeletonCard({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-card 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user