mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +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": {
|
"ModeToggle": {
|
||||||
"switchMode": "Switch to {mode} mode"
|
"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 { ResponseTimeChart } from '@/components/dashboard/response-time-chart'
|
||||||
import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
type RangeDays = 7 | 30 | 90
|
type RangeDays = 7 | 30 | 90
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const t = useTranslations('Dashboard.page')
|
||||||
const { defaultCurrency } = useAuth()
|
const { defaultCurrency } = useAuth()
|
||||||
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
|
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
|
||||||
const [metricsLoading, setMetricsLoading] = useState(true)
|
const [metricsLoading, setMetricsLoading] = useState(true)
|
||||||
@ -122,9 +125,9 @@ export default function DashboardPage() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<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">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Live analytics across conversations, contacts, deals, broadcasts, and automations.
|
{t('description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -135,16 +138,20 @@ export default function DashboardPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Active Conversations"
|
title={t('activeConversations')}
|
||||||
value={metrics.activeConversations.current.toLocaleString()}
|
value={metrics.activeConversations.current.toLocaleString()}
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
delta={{
|
delta={{
|
||||||
sign: metrics.activeConversations.previous,
|
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
|
<MetricCard
|
||||||
title="New Contacts Today"
|
title={t('newContactsToday')}
|
||||||
value={metrics.newContactsToday.current.toLocaleString()}
|
value={metrics.newContactsToday.current.toLocaleString()}
|
||||||
icon={UserPlus}
|
icon={UserPlus}
|
||||||
delta={{
|
delta={{
|
||||||
@ -152,18 +159,19 @@ export default function DashboardPage() {
|
|||||||
metrics.newContactsToday.current - metrics.newContactsToday.previous,
|
metrics.newContactsToday.current - metrics.newContactsToday.previous,
|
||||||
label: deltaLabel(
|
label: deltaLabel(
|
||||||
metrics.newContactsToday.current - metrics.newContactsToday.previous,
|
metrics.newContactsToday.current - metrics.newContactsToday.previous,
|
||||||
'vs yesterday',
|
t('vsYesterday'),
|
||||||
|
t('noChange', { suffix: t('vsYesterday') })
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Open Deals Value"
|
title={t('openDealsValue')}
|
||||||
value={formatCurrency(metrics.openDealsValue, defaultCurrency)}
|
value={formatCurrency(metrics.openDealsValue, defaultCurrency)}
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`}
|
subtitle={t('openDeals', { count: metrics.openDealsCount })}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Messages Sent Today"
|
title={t('messagesSentToday')}
|
||||||
value={metrics.messagesSentToday.current.toLocaleString()}
|
value={metrics.messagesSentToday.current.toLocaleString()}
|
||||||
icon={Send}
|
icon={Send}
|
||||||
delta={{
|
delta={{
|
||||||
@ -171,7 +179,8 @@ export default function DashboardPage() {
|
|||||||
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
|
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
|
||||||
label: deltaLabel(
|
label: deltaLabel(
|
||||||
metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
|
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 {
|
function deltaLabel(delta: number, suffix: string, noChangeLabel: string): string {
|
||||||
if (delta === 0) return `No change ${suffix}`
|
if (delta === 0) return noChangeLabel
|
||||||
const sign = delta > 0 ? '+' : ''
|
const sign = delta > 0 ? '+' : ''
|
||||||
return `${sign}${delta.toLocaleString()} ${suffix}`
|
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' },
|
automation: { icon: Zap, badge: 'bg-rose-500/10 text-rose-400' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||||
|
const t = useTranslations('Dashboard.activityFeed')
|
||||||
// Start at 5 — a quick scan of the most recent events without
|
// Start at 5 — a quick scan of the most recent events without
|
||||||
// dominating vertical real estate. User expands explicitly via the
|
// dominating vertical real estate. User expands explicitly via the
|
||||||
// footer control when they want deeper history.
|
// footer control when they want deeper history.
|
||||||
@ -56,12 +59,12 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="rounded-xl border border-border bg-card">
|
<section className="rounded-xl border border-border bg-card">
|
||||||
<header className="flex items-center justify-between border-b border-border px-5 py-4">
|
<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
|
<Link
|
||||||
href="/inbox"
|
href="/inbox"
|
||||||
className="text-xs font-medium text-primary hover:text-primary/80"
|
className="text-xs font-medium text-primary hover:text-primary/80"
|
||||||
>
|
>
|
||||||
View all →
|
{t('viewAll')}
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -75,8 +78,8 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
|||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Inbox}
|
icon={Inbox}
|
||||||
title="No activity yet"
|
title={t('noActivity')}
|
||||||
hint="Activity from messages, deals, broadcasts, and automations will appear here."
|
hint={t('noActivityHint')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -103,7 +106,7 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
|||||||
{it.text}
|
{it.text}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">
|
<span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||||
{relativeTime(it.at)}
|
{relativeTime(it.at, t)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -122,11 +125,10 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
|||||||
</ul>
|
</ul>
|
||||||
<footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs">
|
<footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs">
|
||||||
<span className="text-muted-foreground tabular-nums">
|
<span className="text-muted-foreground tabular-nums">
|
||||||
Showing {visible.length} of {totalLoaded}
|
{t('showingOf', { visible: visible.length, totalLoaded, plus: totalLoaded === 50 ? '+' : '' })}
|
||||||
{totalLoaded === 50 ? '+' : ''}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<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) => {
|
{PAGE_SIZES.map((size, i) => {
|
||||||
const disabled = !isSizeUseful(size, i)
|
const disabled = !isSizeUseful(size, i)
|
||||||
return (
|
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()
|
const then = new Date(iso).getTime()
|
||||||
if (Number.isNaN(then)) return ''
|
if (Number.isNaN(then)) return ''
|
||||||
const diffSec = Math.round((Date.now() - then) / 1000)
|
const diffSec = Math.round((Date.now() - then) / 1000)
|
||||||
if (diffSec < 60) return `${Math.max(1, diffSec)}s ago`
|
if (diffSec < 60) return t('timeS', { sec: Math.max(1, diffSec) })
|
||||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
|
if (diffSec < 3600) return t('timeM', { min: Math.floor(diffSec / 60) })
|
||||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
|
if (diffSec < 86400) return t('timeH', { hr: Math.floor(diffSec / 3600) })
|
||||||
if (diffSec < 2_592_000) return `${Math.floor(diffSec / 86400)}d ago`
|
if (diffSec < 2_592_000) return t('timeD', { day: Math.floor(diffSec / 86400) })
|
||||||
return new Date(iso).toLocaleDateString()
|
return new Date(iso).toLocaleDateString()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,10 @@ const VB_W = 760
|
|||||||
const VB_H = 240
|
const VB_H = 240
|
||||||
const PADDING = { top: 16, right: 16, bottom: 28, left: 40 }
|
const PADDING = { top: 16, right: 16, bottom: 28, left: 40 }
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) {
|
export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) {
|
||||||
|
const t = useTranslations('Dashboard.conversationsChart')
|
||||||
const data = series[range]
|
const data = series[range]
|
||||||
|
|
||||||
// Memoise the max so per-day hover math doesn't recompute it.
|
// 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">
|
<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">
|
<header className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-foreground">Conversations Over Time</h2>
|
<h2 className="text-sm font-semibold text-foreground">{t('title')}</h2>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">Daily message volume by direction</p>
|
<p className="mt-0.5 text-xs text-muted-foreground">{t('description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-muted/60 p-1">
|
<div className="flex items-center gap-1 rounded-lg bg-muted/60 p-1">
|
||||||
{[7, 30, 90].map((r) => (
|
{[7, 30, 90].map((r) => (
|
||||||
@ -65,7 +68,7 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
|
|||||||
: 'text-muted-foreground hover:text-foreground',
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{r} days
|
{t('days', { count: r })}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -77,17 +80,17 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
|
|||||||
) : data.every((p) => p.incoming === 0 && p.outgoing === 0) ? (
|
) : data.every((p) => p.incoming === 0 && p.outgoing === 0) ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
title="No message activity in this range"
|
title={t('noActivity')}
|
||||||
hint="Send or receive messages to start populating this chart."
|
hint={t('noActivityHint')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<LineSvg data={data} maxY={maxY} ticks={niceTicks} />
|
<LineSvg data={data} maxY={maxY} ticks={niceTicks} t={t} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="flex items-center gap-4 border-t border-border px-5 py-3 text-xs text-muted-foreground">
|
<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="#3b82f6" label={t('incoming')} />
|
||||||
<LegendDot color="#7c3aed" label="Outgoing" />
|
<LegendDot color="#7c3aed" label={t('outgoing')} />
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
@ -101,10 +104,12 @@ function LineSvg({
|
|||||||
data,
|
data,
|
||||||
maxY,
|
maxY,
|
||||||
ticks,
|
ticks,
|
||||||
|
t
|
||||||
}: {
|
}: {
|
||||||
data: ConversationsSeriesPoint[]
|
data: ConversationsSeriesPoint[]
|
||||||
maxY: number
|
maxY: number
|
||||||
ticks: number[]
|
ticks: number[]
|
||||||
|
t: ReturnType<typeof useTranslations>
|
||||||
}) {
|
}) {
|
||||||
// Hover state: both the snapped index AND the tooltip's pixel
|
// Hover state: both the snapped index AND the tooltip's pixel
|
||||||
// offset inside the wrapper div. They're stored together so the
|
// offset inside the wrapper div. They're stored together so the
|
||||||
@ -195,7 +200,7 @@ function LineSvg({
|
|||||||
viewBox={`0 0 ${VB_W} ${VB_H}`}
|
viewBox={`0 0 ${VB_W} ${VB_H}`}
|
||||||
className="h-[240px] w-full"
|
className="h-[240px] w-full"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Conversations per day"
|
aria-label={t('ariaLabel')}
|
||||||
>
|
>
|
||||||
{/* Y-axis gridlines + labels */}
|
{/* Y-axis gridlines + labels */}
|
||||||
{ticks.map((t) => {
|
{ticks.map((t) => {
|
||||||
@ -287,11 +292,11 @@ function LineSvg({
|
|||||||
<div className="mt-1 flex flex-col gap-0.5">
|
<div className="mt-1 flex flex-col gap-0.5">
|
||||||
<span className="flex items-center gap-1.5 text-blue-300">
|
<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" />
|
<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>
|
||||||
<span className="flex items-center gap-1.5 text-primary">
|
<span className="flex items-center gap-1.5 text-primary">
|
||||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
{hovered.outgoing} outgoing
|
{t('tooltipOutgoing', { count: hovered.outgoing })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { BarChart3 } from 'lucide-react'
|
|||||||
import type { ComponentType } from 'react'
|
import type { ComponentType } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared empty-state panel for charts that can't render meaningfully
|
* Shared empty-state panel for charts that can't render meaningfully
|
||||||
* without a minimum amount of data. Kept minimal and uniform so the
|
* without a minimum amount of data. Kept minimal and uniform so the
|
||||||
@ -9,7 +11,7 @@ import { cn } from '@/lib/utils'
|
|||||||
* different widget.
|
* different widget.
|
||||||
*/
|
*/
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
title = 'Not enough data yet',
|
title,
|
||||||
hint,
|
hint,
|
||||||
icon: Icon = BarChart3,
|
icon: Icon = BarChart3,
|
||||||
className,
|
className,
|
||||||
@ -19,6 +21,9 @@ export function EmptyState({
|
|||||||
icon?: ComponentType<{ className?: string }>
|
icon?: ComponentType<{ className?: string }>
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations('Dashboard.emptyState')
|
||||||
|
const defaultTitle = t('title')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
</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>}
|
{hint && <p className="max-w-xs text-xs text-muted-foreground">{hint}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -13,13 +13,16 @@ interface PipelineDonutProps {
|
|||||||
currency: string
|
currency: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
||||||
|
const t = useTranslations('Dashboard.pipelineDonut')
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
|
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
|
||||||
<header className="border-b border-border px-5 py-4">
|
<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">
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
Open deals by stage
|
{t('description')}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -29,8 +32,8 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
|||||||
) : data.stages.length === 0 ? (
|
) : data.stages.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={GitBranch}
|
icon={GitBranch}
|
||||||
title="No open deals yet"
|
title={t('noOpenDeals')}
|
||||||
hint="Create deals in Pipelines to see stage breakdowns here."
|
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="flex-1 truncate text-muted-foreground">{s.name}</span>
|
||||||
<span className="text-muted-foreground tabular-nums">
|
<span className="text-muted-foreground tabular-nums">
|
||||||
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
|
{t('dealCount', { count: s.dealCount })}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-20 text-right text-muted-foreground tabular-nums">
|
<span className="w-20 text-right text-muted-foreground tabular-nums">
|
||||||
{formatCurrencyShort(s.totalValue, currency)}
|
{formatCurrencyShort(s.totalValue, currency)}
|
||||||
@ -67,6 +70,7 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
|||||||
// them for a cleaner look.
|
// them for a cleaner look.
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) {
|
function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) {
|
||||||
|
const t = useTranslations('Dashboard.pipelineDonut')
|
||||||
const size = 200
|
const size = 200
|
||||||
const r = 80
|
const r = 80
|
||||||
const ringWidth = 18
|
const ringWidth = 18
|
||||||
@ -96,7 +100,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center">
|
<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 */}
|
{/* background ring */}
|
||||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} />
|
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} />
|
||||||
{segments.map((seg) => (
|
{segments.map((seg) => (
|
||||||
@ -116,7 +120,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
|
|||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="fill-muted-foreground text-[11px]"
|
className="fill-muted-foreground text-[11px]"
|
||||||
>
|
>
|
||||||
Total
|
{t('total')}
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text
|
||||||
x={cx}
|
x={cx}
|
||||||
|
|||||||
@ -4,25 +4,29 @@ import Link from 'next/link'
|
|||||||
import { UserPlus, Briefcase, Radio, Zap } from 'lucide-react'
|
import { UserPlus, Briefcase, Radio, Zap } from 'lucide-react'
|
||||||
import type { ComponentType } from 'react'
|
import type { ComponentType } from 'react'
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
// Quick-action shortcuts. Each navigates to the page that owns the
|
// Quick-action shortcuts. Each navigates to the page that owns the
|
||||||
// relevant "create" flow. We deliberately don't try to auto-open any
|
// relevant "create" flow. We deliberately don't try to auto-open any
|
||||||
// modal on the target page — that'd require touching those pages,
|
// modal on the target page — that'd require touching those pages,
|
||||||
// which is out of scope here.
|
// which is out of scope here.
|
||||||
interface Action {
|
interface Action {
|
||||||
label: string
|
labelKey: string
|
||||||
href: string
|
href: string
|
||||||
icon: ComponentType<{ className?: string }>
|
icon: ComponentType<{ className?: string }>
|
||||||
tint: string
|
tint: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIONS: Action[] = [
|
const ACTIONS: Action[] = [
|
||||||
{ label: 'New Contact', href: '/contacts', icon: UserPlus, tint: 'text-primary' },
|
{ labelKey: 'newContact', href: '/contacts', icon: UserPlus, tint: 'text-primary' },
|
||||||
{ label: 'New Deal', href: '/pipelines', icon: Briefcase, tint: 'text-blue-400' },
|
{ labelKey: 'newDeal', href: '/pipelines', icon: Briefcase, tint: 'text-blue-400' },
|
||||||
{ label: 'New Broadcast', href: '/broadcasts/new', icon: Radio, tint: 'text-amber-400' },
|
{ labelKey: 'newBroadcast', href: '/broadcasts/new', icon: Radio, tint: 'text-amber-400' },
|
||||||
{ label: 'New Automation', href: '/automations/new', icon: Zap, tint: 'text-primary' },
|
{ labelKey: 'newAutomation', href: '/automations/new', icon: Zap, tint: 'text-primary' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function QuickActions() {
|
export function QuickActions() {
|
||||||
|
const t = useTranslations('Dashboard.quickActions')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{ACTIONS.map((a) => {
|
{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}`}>
|
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-muted ${a.tint}`}>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</div>
|
</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>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -19,6 +19,8 @@ interface ResponseTimeChartProps {
|
|||||||
thresholdMinutes?: number
|
thresholdMinutes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
// Single category, single colour — the data is "average minutes
|
// Single category, single colour — the data is "average minutes
|
||||||
// per weekday". Tremor expects categories as the second tuple in
|
// per weekday". Tremor expects categories as the second tuple in
|
||||||
// the row object, so we shape the buckets into
|
// the row object, so we shape the buckets into
|
||||||
@ -30,6 +32,7 @@ export function ResponseTimeChart({
|
|||||||
loading,
|
loading,
|
||||||
thresholdMinutes = 5,
|
thresholdMinutes = 5,
|
||||||
}: ResponseTimeChartProps) {
|
}: ResponseTimeChartProps) {
|
||||||
|
const t = useTranslations('Dashboard.responseTimeChart')
|
||||||
const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false
|
const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false
|
||||||
|
|
||||||
// Map buckets → Tremor rows. Null `avgMinutes` (no samples)
|
// 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">
|
<header className="flex items-center justify-between gap-3 border-b border-border px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-foreground">
|
<h2 className="text-sm font-semibold text-foreground">
|
||||||
Average First Response Time
|
{t('title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
Minutes to reply to a customer's first unreplied message, by
|
{t('description')}
|
||||||
weekday
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-right text-xs">
|
<div className="flex items-center gap-3 text-right text-xs">
|
||||||
{thresholdMinutes > 0 && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
|
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
This week:{' '}
|
{t('thisWeek')}{' '}
|
||||||
<span className="font-medium text-foreground tabular-nums">
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
{fmt(data.thisWeekAvg)}
|
{fmt(data.thisWeekAvg)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
Last week:{' '}
|
{t('lastWeek')}{' '}
|
||||||
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
|
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -84,8 +86,8 @@ export function ResponseTimeChart({
|
|||||||
) : !hasData ? (
|
) : !hasData ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
title="No replies recorded yet"
|
title={t('noReplies')}
|
||||||
hint="This chart fills in as you reply to customer messages."
|
hint={t('noRepliesHint')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BarChart
|
<BarChart
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user