mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Self-review of the light/dark sweep surfaced neutral colors that lived in raw inline-style / <style jsx> / gradient values (so the className sweep correctly skipped them) and a few contrast/affordance nits: - reply-quote: adapt to the outbound (primary) bubble via onPrimary so the quote/author stay legible on the violet fill in light mode; wire it from message-bubble. - dashboard SVG chrome -> tokens: conversations-chart gridline/crosshair, pipeline-donut background ring, automation-builder dot-grid gradient. - members-tab viewer chip: bg-card -> bg-transparent (outline) so it doesn't blend into a card surface in light mode. - tag-manager / pipeline-settings color-swatch selection rings: raw slate/white -> var(--popover)/var(--foreground) so they flip. - pipeline-board scrollbar (<style jsx>): slate rgb -> var tokens. - activity-feed zebra stripe: bg-card/40 -> bg-muted/40 (survives light). - forgot-password / import-modal: restore real hover (dead no-op hovers). - themed-toaster: gate theme via useSyncExternalStore to avoid a hydration mismatch on sonner's theme attribute (no setState-in-effect). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
"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}`
|
||
}
|