mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(i18n): translate dashboard overview components
- Added `Dashboard` namespace to English dictionary. - Translated metric cards, quick actions, and activity feed. - Localized charts (conversations, pipeline donut, response time) and empty states.
This commit is contained in:
parent
738b0dde1f
commit
afa136245b
@ -59,5 +59,70 @@
|
||||
},
|
||||
"ModeToggle": {
|
||||
"switchMode": "Switch to {mode} mode"
|
||||
},
|
||||
"Dashboard": {
|
||||
"page": {
|
||||
"title": "Dashboard",
|
||||
"description": "Live analytics across conversations, contacts, deals, broadcasts, and automations.",
|
||||
"activeConversations": "Active Conversations",
|
||||
"newContactsToday": "New Contacts Today",
|
||||
"openDealsValue": "Open Deals Value",
|
||||
"messagesSentToday": "Messages Sent Today",
|
||||
"newTodayVsYesterday": "new today vs yesterday",
|
||||
"vsYesterday": "vs yesterday",
|
||||
"openDeals": "{count} open {count, plural, =1 {deal} other {deals}}",
|
||||
"noChange": "No change {suffix}"
|
||||
},
|
||||
"quickActions": {
|
||||
"newContact": "New Contact",
|
||||
"newDeal": "New Deal",
|
||||
"newBroadcast": "New Broadcast",
|
||||
"newAutomation": "New Automation"
|
||||
},
|
||||
"activityFeed": {
|
||||
"title": "Recent Activity",
|
||||
"viewAll": "View all →",
|
||||
"noActivity": "No activity yet",
|
||||
"noActivityHint": "Activity from messages, deals, broadcasts, and automations will appear here.",
|
||||
"showingOf": "Showing {visible} of {totalLoaded}{plus}",
|
||||
"show": "Show",
|
||||
"timeS": "{sec}s ago",
|
||||
"timeM": "{min}m ago",
|
||||
"timeH": "{hr}h ago",
|
||||
"timeD": "{day}d ago"
|
||||
},
|
||||
"conversationsChart": {
|
||||
"title": "Conversations Over Time",
|
||||
"description": "Daily message volume by direction",
|
||||
"days": "{count} days",
|
||||
"noActivity": "No message activity in this range",
|
||||
"noActivityHint": "Send or receive messages to start populating this chart.",
|
||||
"incoming": "Incoming",
|
||||
"outgoing": "Outgoing",
|
||||
"tooltipIncoming": "{count} incoming",
|
||||
"tooltipOutgoing": "{count} outgoing",
|
||||
"ariaLabel": "Conversations per day"
|
||||
},
|
||||
"pipelineDonut": {
|
||||
"title": "Pipeline Value",
|
||||
"description": "Open deals by stage",
|
||||
"noOpenDeals": "No open deals yet",
|
||||
"noOpenDealsHint": "Create deals in Pipelines to see stage breakdowns here.",
|
||||
"dealCount": "{count} {count, plural, =1 {deal} other {deals}}",
|
||||
"total": "Total",
|
||||
"ariaLabel": "Pipeline value by stage"
|
||||
},
|
||||
"responseTimeChart": {
|
||||
"title": "Average First Response Time",
|
||||
"description": "Minutes to reply to a customer's first unreplied message, by weekday",
|
||||
"target": "target {minutes}m",
|
||||
"thisWeek": "This week:",
|
||||
"lastWeek": "Last week:",
|
||||
"noReplies": "No replies recorded yet",
|
||||
"noRepliesHint": "This chart fills in as you reply to customer messages."
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "Not enough data yet"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,9 +34,12 @@ import { PipelineDonut } from '@/components/dashboard/pipeline-donut'
|
||||
import { ResponseTimeChart } from '@/components/dashboard/response-time-chart'
|
||||
import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
type RangeDays = 7 | 30 | 90
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations('Dashboard.page')
|
||||
const { defaultCurrency } = useAuth()
|
||||
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
|
||||
const [metricsLoading, setMetricsLoading] = useState(true)
|
||||
@ -122,9 +125,9 @@ export default function DashboardPage() {
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Live analytics across conversations, contacts, deals, broadcasts, and automations.
|
||||
{t('description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -135,16 +138,20 @@ export default function DashboardPage() {
|
||||
) : (
|
||||
<>
|
||||
<MetricCard
|
||||
title="Active Conversations"
|
||||
title={t('activeConversations')}
|
||||
value={metrics.activeConversations.current.toLocaleString()}
|
||||
icon={MessageSquare}
|
||||
delta={{
|
||||
sign: metrics.activeConversations.previous,
|
||||
label: deltaLabel(metrics.activeConversations.previous, 'new today vs yesterday'),
|
||||
label: deltaLabel(
|
||||
metrics.activeConversations.previous,
|
||||
t('newTodayVsYesterday'),
|
||||
t('noChange', { suffix: t('newTodayVsYesterday') })
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<MetricCard
|
||||
title="New Contacts Today"
|
||||
title={t('newContactsToday')}
|
||||
value={metrics.newContactsToday.current.toLocaleString()}
|
||||
icon={UserPlus}
|
||||
delta={{
|
||||
@ -152,18 +159,19 @@ export default function DashboardPage() {
|
||||
metrics.newContactsToday.current - metrics.newContactsToday.previous,
|
||||
label: deltaLabel(
|
||||
metrics.newContactsToday.current - metrics.newContactsToday.previous,
|
||||
'vs yesterday',
|
||||
t('vsYesterday'),
|
||||
t('noChange', { suffix: t('vsYesterday') })
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Open Deals Value"
|
||||
title={t('openDealsValue')}
|
||||
value={formatCurrency(metrics.openDealsValue, defaultCurrency)}
|
||||
icon={DollarSign}
|
||||
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`}
|
||||
subtitle={t('openDeals', { count: metrics.openDealsCount })}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Messages Sent Today"
|
||||
title={t('messagesSentToday')}
|
||||
value={metrics.messagesSentToday.current.toLocaleString()}
|
||||
icon={Send}
|
||||
delta={{
|
||||
@ -171,7 +179,8 @@ export default function DashboardPage() {
|
||||
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
|
||||
label: deltaLabel(
|
||||
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
|
||||
'vs yesterday',
|
||||
t('vsYesterday'),
|
||||
t('noChange', { suffix: t('vsYesterday') })
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@ -218,8 +227,8 @@ export default function DashboardPage() {
|
||||
|
||||
// ------------------------------------------------------------
|
||||
|
||||
function deltaLabel(delta: number, suffix: string): string {
|
||||
if (delta === 0) return `No change ${suffix}`
|
||||
function deltaLabel(delta: number, suffix: string, noChangeLabel: string): string {
|
||||
if (delta === 0) return noChangeLabel
|
||||
const sign = delta > 0 ? '+' : ''
|
||||
return `${sign}${delta.toLocaleString()} ${suffix}`
|
||||
}
|
||||
|
||||
@ -38,7 +38,10 @@ const KIND_THEME: Record<ActivityKind, KindTheme> = {
|
||||
automation: { icon: Zap, badge: 'bg-rose-500/10 text-rose-400' },
|
||||
}
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
const t = useTranslations('Dashboard.activityFeed')
|
||||
// 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.
|
||||
@ -56,12 +59,12 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
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>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('title')}</h2>
|
||||
<Link
|
||||
href="/inbox"
|
||||
className="text-xs font-medium text-primary hover:text-primary/80"
|
||||
>
|
||||
View all →
|
||||
{t('viewAll')}
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
@ -75,8 +78,8 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
<div className="p-5">
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No activity yet"
|
||||
hint="Activity from messages, deals, broadcasts, and automations will appear here."
|
||||
title={t('noActivity')}
|
||||
hint={t('noActivityHint')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@ -103,7 +106,7 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
{it.text}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{relativeTime(it.at)}
|
||||
{relativeTime(it.at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@ -122,11 +125,10 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
</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 ? '+' : ''}
|
||||
{t('showingOf', { visible: visible.length, totalLoaded, plus: totalLoaded === 50 ? '+' : '' })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="mr-1 text-muted-foreground">Show</span>
|
||||
<span className="mr-1 text-muted-foreground">{t('show')}</span>
|
||||
{PAGE_SIZES.map((size, i) => {
|
||||
const disabled = !isSizeUseful(size, i)
|
||||
return (
|
||||
@ -155,13 +157,13 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
function relativeTime(iso: string, t: ReturnType<typeof useTranslations>): 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`
|
||||
if (diffSec < 60) return t('timeS', { sec: Math.max(1, diffSec) })
|
||||
if (diffSec < 3600) return t('timeM', { min: Math.floor(diffSec / 60) })
|
||||
if (diffSec < 86400) return t('timeH', { hr: Math.floor(diffSec / 3600) })
|
||||
if (diffSec < 2_592_000) return t('timeD', { day: Math.floor(diffSec / 86400) })
|
||||
return new Date(iso).toLocaleDateString()
|
||||
}
|
||||
|
||||
@ -27,7 +27,10 @@ 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.
|
||||
@ -49,8 +52,8 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
|
||||
<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>
|
||||
<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) => (
|
||||
@ -65,7 +68,7 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{r} days
|
||||
{t('days', { count: r })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -77,17 +80,17 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
|
||||
) : 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."
|
||||
title={t('noActivity')}
|
||||
hint={t('noActivityHint')}
|
||||
/>
|
||||
) : (
|
||||
<LineSvg data={data} maxY={maxY} ticks={niceTicks} />
|
||||
<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="Incoming" />
|
||||
<LegendDot color="#7c3aed" label="Outgoing" />
|
||||
<LegendDot color="#3b82f6" label={t('incoming')} />
|
||||
<LegendDot color="#7c3aed" label={t('outgoing')} />
|
||||
</footer>
|
||||
</section>
|
||||
)
|
||||
@ -101,10 +104,12 @@ 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
|
||||
@ -195,7 +200,7 @@ function LineSvg({
|
||||
viewBox={`0 0 ${VB_W} ${VB_H}`}
|
||||
className="h-[240px] w-full"
|
||||
role="img"
|
||||
aria-label="Conversations per day"
|
||||
aria-label={t('ariaLabel')}
|
||||
>
|
||||
{/* Y-axis gridlines + labels */}
|
||||
{ticks.map((t) => {
|
||||
@ -287,11 +292,11 @@ function LineSvg({
|
||||
<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
|
||||
{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" />
|
||||
{hovered.outgoing} outgoing
|
||||
{t('tooltipOutgoing', { count: hovered.outgoing })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,8 @@ import { BarChart3 } from 'lucide-react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
/**
|
||||
* Shared empty-state panel for charts that can't render meaningfully
|
||||
* without a minimum amount of data. Kept minimal and uniform so the
|
||||
@ -9,7 +11,7 @@ import { cn } from '@/lib/utils'
|
||||
* different widget.
|
||||
*/
|
||||
export function EmptyState({
|
||||
title = 'Not enough data yet',
|
||||
title,
|
||||
hint,
|
||||
icon: Icon = BarChart3,
|
||||
className,
|
||||
@ -19,6 +21,9 @@ export function EmptyState({
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
className?: string
|
||||
}) {
|
||||
const t = useTranslations('Dashboard.emptyState')
|
||||
const defaultTitle = t('title')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -29,7 +34,7 @@ export function EmptyState({
|
||||
<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>
|
||||
<p className="text-sm font-medium text-muted-foreground">{title || defaultTitle}</p>
|
||||
{hint && <p className="max-w-xs text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -13,13 +13,16 @@ interface PipelineDonutProps {
|
||||
currency: string
|
||||
}
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
||||
const t = useTranslations('Dashboard.pipelineDonut')
|
||||
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>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('title')}</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Open deals by stage
|
||||
{t('description')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@ -29,8 +32,8 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
||||
) : data.stages.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={GitBranch}
|
||||
title="No open deals yet"
|
||||
hint="Create deals in Pipelines to see stage breakdowns here."
|
||||
title={t('noOpenDeals')}
|
||||
hint={t('noOpenDealsHint')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@ -45,7 +48,7 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
||||
/>
|
||||
<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'}
|
||||
{t('dealCount', { count: s.dealCount })}
|
||||
</span>
|
||||
<span className="w-20 text-right text-muted-foreground tabular-nums">
|
||||
{formatCurrencyShort(s.totalValue, currency)}
|
||||
@ -67,6 +70,7 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
||||
// them for a cleaner look.
|
||||
// ------------------------------------------------------------
|
||||
function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) {
|
||||
const t = useTranslations('Dashboard.pipelineDonut')
|
||||
const size = 200
|
||||
const r = 80
|
||||
const ringWidth = 18
|
||||
@ -96,7 +100,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
|
||||
|
||||
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">
|
||||
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label={t('ariaLabel')}>
|
||||
{/* background ring */}
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} />
|
||||
{segments.map((seg) => (
|
||||
@ -116,7 +120,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
Total
|
||||
{t('total')}
|
||||
</text>
|
||||
<text
|
||||
x={cx}
|
||||
|
||||
@ -4,25 +4,29 @@ import Link from 'next/link'
|
||||
import { UserPlus, Briefcase, Radio, Zap } from 'lucide-react'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
// 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
|
||||
labelKey: 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' },
|
||||
{ labelKey: 'newContact', href: '/contacts', icon: UserPlus, tint: 'text-primary' },
|
||||
{ labelKey: 'newDeal', href: '/pipelines', icon: Briefcase, tint: 'text-blue-400' },
|
||||
{ labelKey: 'newBroadcast', href: '/broadcasts/new', icon: Radio, tint: 'text-amber-400' },
|
||||
{ labelKey: 'newAutomation', href: '/automations/new', icon: Zap, tint: 'text-primary' },
|
||||
]
|
||||
|
||||
export function QuickActions() {
|
||||
const t = useTranslations('Dashboard.quickActions')
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{ACTIONS.map((a) => {
|
||||
@ -36,7 +40,7 @@ export function QuickActions() {
|
||||
<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>
|
||||
<span className="text-sm font-medium text-foreground">{t(a.labelKey as string)}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -19,6 +19,8 @@ interface ResponseTimeChartProps {
|
||||
thresholdMinutes?: number
|
||||
}
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
// 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
|
||||
@ -30,6 +32,7 @@ export function ResponseTimeChart({
|
||||
loading,
|
||||
thresholdMinutes = 5,
|
||||
}: ResponseTimeChartProps) {
|
||||
const t = useTranslations('Dashboard.responseTimeChart')
|
||||
const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false
|
||||
|
||||
// Map buckets → Tremor rows. Null `avgMinutes` (no samples)
|
||||
@ -48,29 +51,28 @@ export function ResponseTimeChart({
|
||||
<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
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Minutes to reply to a customer's first unreplied message, by
|
||||
weekday
|
||||
{t('description')}
|
||||
</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
|
||||
{t('target', { minutes: thresholdMinutes })}
|
||||
</span>
|
||||
)}
|
||||
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
|
||||
<div>
|
||||
<div className="text-muted-foreground">
|
||||
This week:{' '}
|
||||
{t('thisWeek')}{' '}
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{fmt(data.thisWeekAvg)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Last week:{' '}
|
||||
{t('lastWeek')}{' '}
|
||||
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -84,8 +86,8 @@ export function ResponseTimeChart({
|
||||
) : !hasData ? (
|
||||
<EmptyState
|
||||
icon={Clock}
|
||||
title="No replies recorded yet"
|
||||
hint="This chart fills in as you reply to customer messages."
|
||||
title={t('noReplies')}
|
||||
hint={t('noRepliesHint')}
|
||||
/>
|
||||
) : (
|
||||
<BarChart
|
||||
|
||||
Loading…
Reference in New Issue
Block a user