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:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View 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()
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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}`
}

View 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>
)
}

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

View 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>
)
}