@wealthx/shadcn 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +154 -138
- package/CHANGELOG.md +6 -0
- package/README.md +82 -0
- package/dist/chunk-3EQP72AW.mjs +58 -0
- package/dist/chunk-5JGQAAQV.mjs +212 -0
- package/dist/chunk-GLW2UO6O.mjs +212 -0
- package/dist/chunk-RN67642N.mjs +171 -0
- package/dist/chunk-UEL4RD5P.mjs +272 -0
- package/dist/chunk-YBXCIF5Q.mjs +198 -0
- package/dist/components/ui/cashflow-bar-chart.js +596 -0
- package/dist/components/ui/cashflow-bar-chart.mjs +16 -0
- package/dist/components/ui/combobox.js +261 -0
- package/dist/components/ui/combobox.mjs +28 -0
- package/dist/components/ui/data-table.mjs +3 -3
- package/dist/components/ui/expense-bar-chart.js +543 -0
- package/dist/components/ui/expense-bar-chart.mjs +16 -0
- package/dist/components/ui/field.mjs +2 -2
- package/dist/components/ui/income-bar-chart.js +543 -0
- package/dist/components/ui/income-bar-chart.mjs +16 -0
- package/dist/components/ui/transactions-income-expense-bar-chart.js +478 -0
- package/dist/components/ui/transactions-income-expense-bar-chart.mjs +16 -0
- package/dist/index.js +1685 -725
- package/dist/index.mjs +152 -111
- package/dist/styles.css +1 -1
- package/package.json +30 -2
- package/src/components/index.tsx +56 -0
- package/src/components/ui/cashflow-bar-chart.tsx +336 -0
- package/src/components/ui/chart-shared.tsx +100 -0
- package/src/components/ui/combobox.tsx +217 -0
- package/src/components/ui/expense-bar-chart.tsx +278 -0
- package/src/components/ui/income-bar-chart.tsx +278 -0
- package/src/components/ui/transactions-income-expense-bar-chart.tsx +198 -0
- package/src/styles/styles-css.ts +1 -1
- package/tsup.config.ts +5 -0
- package/dist/{chunk-K76E2TQU.mjs → chunk-CJ46PDXE.mjs} +5 -5
- package/dist/{chunk-HUVTPUV2.mjs → chunk-NLLKTU4B.mjs} +3 -3
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Chart as ChartJS,
|
|
4
|
+
CategoryScale,
|
|
5
|
+
LinearScale,
|
|
6
|
+
BarElement,
|
|
7
|
+
Tooltip,
|
|
8
|
+
Legend,
|
|
9
|
+
type ChartOptions,
|
|
10
|
+
type ChartData,
|
|
11
|
+
} from "chart.js";
|
|
12
|
+
import { Chart } from "react-chartjs-2";
|
|
13
|
+
import { useThemeVars } from "@/lib/theme-provider";
|
|
14
|
+
import { Card, CardContent, CardHeader, CardTitle, CardAction } from "./card";
|
|
15
|
+
import { Empty, EmptyDescription } from "./empty";
|
|
16
|
+
import { Skeleton } from "./skeleton";
|
|
17
|
+
import { cn } from "@/lib/utils";
|
|
18
|
+
import {
|
|
19
|
+
hexToRgba,
|
|
20
|
+
DATASET_ALPHAS,
|
|
21
|
+
FALLBACK_TICK,
|
|
22
|
+
CHART_SLICE_COUNT,
|
|
23
|
+
CHART_PERIODS,
|
|
24
|
+
formatTooltipDate,
|
|
25
|
+
ChartLegendItem,
|
|
26
|
+
ChartPeriodButton,
|
|
27
|
+
type ChartPeriod,
|
|
28
|
+
type ChartGranularity,
|
|
29
|
+
} from "./chart-shared";
|
|
30
|
+
|
|
31
|
+
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend);
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Types
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export interface IncomeDataset {
|
|
38
|
+
/** Income source label, e.g. "Employment", "Rental", "Other" */
|
|
39
|
+
label: string;
|
|
40
|
+
/** One value per data point, aligned to the months array */
|
|
41
|
+
data: number[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface IncomeBarChartData {
|
|
45
|
+
/** Display labels, e.g. ["Jul", "Aug"] for monthly or ["Mar 8", "Mar 9"] for daily */
|
|
46
|
+
months: string[];
|
|
47
|
+
/** Optional ISO date strings per point — used for the tooltip title */
|
|
48
|
+
dates?: string[];
|
|
49
|
+
datasets: IncomeDataset[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type IncomePeriod = ChartPeriod;
|
|
53
|
+
export type IncomeGranularity = ChartGranularity;
|
|
54
|
+
|
|
55
|
+
export interface IncomeBarChartProps {
|
|
56
|
+
/** Full dataset — sliced to the selected period */
|
|
57
|
+
incomeData: IncomeBarChartData | null;
|
|
58
|
+
title?: string;
|
|
59
|
+
/** Show or hide the chart legend */
|
|
60
|
+
showLegend?: boolean;
|
|
61
|
+
/** Show or hide X axis labels */
|
|
62
|
+
showXAxis?: boolean;
|
|
63
|
+
/** Show or hide Y axis labels */
|
|
64
|
+
showYAxis?: boolean;
|
|
65
|
+
/** Legend placement relative to chart */
|
|
66
|
+
legendPosition?: "top" | "bottom";
|
|
67
|
+
/** Default period selector value */
|
|
68
|
+
defaultPeriod?: IncomePeriod;
|
|
69
|
+
/**
|
|
70
|
+
* Data granularity — controls available period buttons and slice counts.
|
|
71
|
+
* "monthly" (default): shows 3M / 6M / 12M, slices by month count.
|
|
72
|
+
* "daily": shows 1M / 3M / 6M / 12M, slices by day count (1M=30, 3M=90, etc.).
|
|
73
|
+
*/
|
|
74
|
+
granularity?: IncomeGranularity;
|
|
75
|
+
/** Chart canvas height in pixels */
|
|
76
|
+
height?: number;
|
|
77
|
+
/** Width of the card in pixels */
|
|
78
|
+
width?: number | string;
|
|
79
|
+
className?: string;
|
|
80
|
+
/** Show skeleton loading state instead of the chart */
|
|
81
|
+
isLoading?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Constants
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const FALLBACK_PRIMARY = "#33FF99";
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Component
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export function IncomeBarChart({
|
|
95
|
+
incomeData,
|
|
96
|
+
title = "Income",
|
|
97
|
+
showLegend = true,
|
|
98
|
+
showXAxis = true,
|
|
99
|
+
showYAxis = true,
|
|
100
|
+
legendPosition = "top",
|
|
101
|
+
defaultPeriod = 6,
|
|
102
|
+
granularity = "monthly",
|
|
103
|
+
height = 280,
|
|
104
|
+
width = "100%",
|
|
105
|
+
className,
|
|
106
|
+
isLoading = false,
|
|
107
|
+
}: IncomeBarChartProps) {
|
|
108
|
+
const periods = CHART_PERIODS[granularity];
|
|
109
|
+
const [period, setPeriod] = useState<IncomePeriod>(defaultPeriod);
|
|
110
|
+
|
|
111
|
+
// Reset period when granularity changes, but not on initial mount
|
|
112
|
+
// (defaultPeriod handles the initial value).
|
|
113
|
+
const isFirstRender = useRef(true);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (isFirstRender.current) { isFirstRender.current = false; return; }
|
|
116
|
+
setPeriod(CHART_PERIODS[granularity][0]);
|
|
117
|
+
}, [granularity]);
|
|
118
|
+
|
|
119
|
+
const themeVars = useThemeVars();
|
|
120
|
+
const brandPrimary: string =
|
|
121
|
+
(themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY;
|
|
122
|
+
|
|
123
|
+
const sliced = useMemo<IncomeBarChartData | null>(() => {
|
|
124
|
+
if (!incomeData?.months?.length || !incomeData.datasets.length) return null;
|
|
125
|
+
const count = Math.min(CHART_SLICE_COUNT[granularity][period], incomeData.months.length);
|
|
126
|
+
const start = incomeData.months.length - count;
|
|
127
|
+
return {
|
|
128
|
+
months: incomeData.months.slice(start),
|
|
129
|
+
dates: incomeData.dates?.slice(start),
|
|
130
|
+
datasets: incomeData.datasets.map((ds) => ({
|
|
131
|
+
...ds,
|
|
132
|
+
data: ds.data.slice(start),
|
|
133
|
+
})),
|
|
134
|
+
};
|
|
135
|
+
}, [incomeData, period, granularity]);
|
|
136
|
+
|
|
137
|
+
const datasetColors = useMemo(
|
|
138
|
+
() => sliced?.datasets.map((_, i) =>
|
|
139
|
+
hexToRgba(brandPrimary, DATASET_ALPHAS[i % DATASET_ALPHAS.length])
|
|
140
|
+
) ?? [],
|
|
141
|
+
[sliced, brandPrimary]
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const chartData = useMemo<ChartData<"bar">>(() => {
|
|
145
|
+
if (!sliced) return { labels: [], datasets: [] };
|
|
146
|
+
return {
|
|
147
|
+
labels: sliced.months,
|
|
148
|
+
datasets: sliced.datasets.map((ds, i) => ({
|
|
149
|
+
label: ds.label,
|
|
150
|
+
data: ds.data,
|
|
151
|
+
backgroundColor: datasetColors[i],
|
|
152
|
+
hoverBackgroundColor: hexToRgba(
|
|
153
|
+
brandPrimary,
|
|
154
|
+
Math.min(DATASET_ALPHAS[i % DATASET_ALPHAS.length] + 0.15, 1)
|
|
155
|
+
),
|
|
156
|
+
borderWidth: 0,
|
|
157
|
+
borderRadius: 0,
|
|
158
|
+
borderSkipped: false,
|
|
159
|
+
barPercentage: 0.75,
|
|
160
|
+
categoryPercentage: 0.7,
|
|
161
|
+
stack: "income",
|
|
162
|
+
})),
|
|
163
|
+
};
|
|
164
|
+
}, [sliced, datasetColors, brandPrimary]);
|
|
165
|
+
|
|
166
|
+
const options = useMemo<ChartOptions<"bar">>(() => ({
|
|
167
|
+
responsive: true,
|
|
168
|
+
maintainAspectRatio: false,
|
|
169
|
+
animation: { duration: 800, easing: "easeOutQuart" },
|
|
170
|
+
layout: { padding: 0 },
|
|
171
|
+
plugins: {
|
|
172
|
+
legend: { display: false },
|
|
173
|
+
tooltip: {
|
|
174
|
+
mode: "index",
|
|
175
|
+
intersect: false,
|
|
176
|
+
padding: 12,
|
|
177
|
+
cornerRadius: 0,
|
|
178
|
+
titleFont: { size: 11, weight: "600" },
|
|
179
|
+
bodyFont: { size: 12, weight: "500" },
|
|
180
|
+
callbacks: {
|
|
181
|
+
title: (tooltipItems) => {
|
|
182
|
+
const idx = tooltipItems[0]?.dataIndex;
|
|
183
|
+
if (idx != null && sliced?.dates?.[idx]) {
|
|
184
|
+
return formatTooltipDate(sliced.dates[idx], granularity);
|
|
185
|
+
}
|
|
186
|
+
return tooltipItems[0]?.label ?? "";
|
|
187
|
+
},
|
|
188
|
+
label: (ctx) => {
|
|
189
|
+
const val = ctx.raw as number;
|
|
190
|
+
if (val === 0) return null;
|
|
191
|
+
return ` ${ctx.dataset.label}: $${val.toLocaleString()}`;
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
scales: {
|
|
197
|
+
x: {
|
|
198
|
+
display: showXAxis,
|
|
199
|
+
stacked: true,
|
|
200
|
+
grid: { display: false },
|
|
201
|
+
border: { display: false },
|
|
202
|
+
ticks: { font: { size: 10 }, color: FALLBACK_TICK },
|
|
203
|
+
},
|
|
204
|
+
y: {
|
|
205
|
+
display: showYAxis,
|
|
206
|
+
stacked: true,
|
|
207
|
+
grid: { display: false },
|
|
208
|
+
border: { display: false },
|
|
209
|
+
ticks: {
|
|
210
|
+
font: { size: 10 },
|
|
211
|
+
color: FALLBACK_TICK,
|
|
212
|
+
maxTicksLimit: 5,
|
|
213
|
+
padding: 8,
|
|
214
|
+
callback: (v) => `$${Number(v).toLocaleString()}`,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
}), [showXAxis, showYAxis, sliced, granularity]);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<Card
|
|
222
|
+
className={cn("w-full py-4 sm:py-6 gap-2", className)}
|
|
223
|
+
style={{ maxWidth: width }}
|
|
224
|
+
>
|
|
225
|
+
<CardHeader className="px-3 sm:px-6">
|
|
226
|
+
<CardTitle className="text-sm sm:text-base">{title}</CardTitle>
|
|
227
|
+
<CardAction>
|
|
228
|
+
<div className="flex gap-0.5 sm:gap-1">
|
|
229
|
+
{periods.map((p) => (
|
|
230
|
+
<ChartPeriodButton
|
|
231
|
+
key={p}
|
|
232
|
+
period={p}
|
|
233
|
+
active={period === p}
|
|
234
|
+
onClick={() => setPeriod(p)}
|
|
235
|
+
/>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
</CardAction>
|
|
239
|
+
</CardHeader>
|
|
240
|
+
|
|
241
|
+
<CardContent className="px-3 sm:px-6">
|
|
242
|
+
{isLoading ? (
|
|
243
|
+
<Skeleton style={{ height, width: "100%" }} />
|
|
244
|
+
) : !sliced ? (
|
|
245
|
+
<Empty className="flex-none p-4" style={{ height }}>
|
|
246
|
+
<EmptyDescription>No data available</EmptyDescription>
|
|
247
|
+
</Empty>
|
|
248
|
+
) : (
|
|
249
|
+
<div className="flex flex-col gap-2">
|
|
250
|
+
{showLegend && legendPosition === "top" && (
|
|
251
|
+
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pb-2">
|
|
252
|
+
{sliced.datasets.map((ds, i) => (
|
|
253
|
+
<ChartLegendItem key={ds.label} label={ds.label} color={datasetColors[i]} />
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
<div style={{ height, width: "100%", position: "relative" }}>
|
|
258
|
+
<Chart
|
|
259
|
+
key={brandPrimary}
|
|
260
|
+
type="bar"
|
|
261
|
+
data={chartData}
|
|
262
|
+
options={options}
|
|
263
|
+
aria-label={title}
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
{showLegend && legendPosition === "bottom" && (
|
|
267
|
+
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pt-2">
|
|
268
|
+
{sliced.datasets.map((ds, i) => (
|
|
269
|
+
<ChartLegendItem key={ds.label} label={ds.label} color={datasetColors[i]} />
|
|
270
|
+
))}
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</CardContent>
|
|
276
|
+
</Card>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Chart as ChartJS,
|
|
4
|
+
CategoryScale,
|
|
5
|
+
LinearScale,
|
|
6
|
+
BarElement,
|
|
7
|
+
Tooltip,
|
|
8
|
+
type ChartOptions,
|
|
9
|
+
type ChartData,
|
|
10
|
+
} from "chart.js";
|
|
11
|
+
import ChartDataLabels from "chartjs-plugin-datalabels";
|
|
12
|
+
import { Chart } from "react-chartjs-2";
|
|
13
|
+
import { useThemeVars } from "@/lib/theme-provider";
|
|
14
|
+
import { Card, CardContent, CardHeader, CardTitle } from "./card";
|
|
15
|
+
import { Empty, EmptyDescription } from "./empty";
|
|
16
|
+
import { Spinner } from "./spinner";
|
|
17
|
+
import { cn } from "@/lib/utils";
|
|
18
|
+
import { FALLBACK_TICK } from "./chart-shared";
|
|
19
|
+
|
|
20
|
+
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip);
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Constants
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const FALLBACK_PRIMARY = "#33FF99";
|
|
27
|
+
const FALLBACK_SECONDARY = "#162029";
|
|
28
|
+
/** Dark neutral used for dollar value labels — not a brand color. */
|
|
29
|
+
const VALUE_LABEL_COLOR = "#162029";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function formatDollar(value: number): string {
|
|
36
|
+
return `$${value.toLocaleString(undefined, {
|
|
37
|
+
minimumFractionDigits: 2,
|
|
38
|
+
maximumFractionDigits: 2,
|
|
39
|
+
})}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Types
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export interface TransactionsIncomeExpenseBarChartProps {
|
|
47
|
+
/** Total income amount (positive number) */
|
|
48
|
+
totalIncome: number | null;
|
|
49
|
+
/** Total expense amount (negative or positive — absolute value is used) */
|
|
50
|
+
totalExpense: number | null;
|
|
51
|
+
title?: string;
|
|
52
|
+
/** Chart canvas height in pixels */
|
|
53
|
+
height?: number;
|
|
54
|
+
/** Width of the card */
|
|
55
|
+
width?: number | string;
|
|
56
|
+
className?: string;
|
|
57
|
+
/** Show skeleton loading state instead of the chart */
|
|
58
|
+
isLoading?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Component
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export function TransactionsIncomeExpenseBarChart({
|
|
66
|
+
totalIncome,
|
|
67
|
+
totalExpense,
|
|
68
|
+
title = "Transactions — Income vs Expense",
|
|
69
|
+
height = 120,
|
|
70
|
+
width = "100%",
|
|
71
|
+
className,
|
|
72
|
+
isLoading = false,
|
|
73
|
+
}: TransactionsIncomeExpenseBarChartProps) {
|
|
74
|
+
const themeVars = useThemeVars();
|
|
75
|
+
const brandPrimary =
|
|
76
|
+
(themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY;
|
|
77
|
+
const brandSecondary =
|
|
78
|
+
(themeVars["--theme-secondary"] as string | undefined) || FALLBACK_SECONDARY;
|
|
79
|
+
|
|
80
|
+
const hasData = totalIncome != null && totalExpense != null;
|
|
81
|
+
const incomeVal = totalIncome ?? 0;
|
|
82
|
+
const expenseVal = Math.abs(totalExpense ?? 0);
|
|
83
|
+
const maxVal = Math.max(incomeVal, expenseVal);
|
|
84
|
+
|
|
85
|
+
const chartData = useMemo<ChartData<"bar">>(() => {
|
|
86
|
+
if (!hasData) return { labels: [], datasets: [] };
|
|
87
|
+
return {
|
|
88
|
+
labels: ["Incoming", "Outgoing"],
|
|
89
|
+
datasets: [
|
|
90
|
+
{
|
|
91
|
+
barThickness: 40,
|
|
92
|
+
backgroundColor: [brandPrimary, brandSecondary],
|
|
93
|
+
hoverBackgroundColor: [brandPrimary, brandSecondary],
|
|
94
|
+
borderWidth: 0,
|
|
95
|
+
borderRadius: 0,
|
|
96
|
+
borderSkipped: false,
|
|
97
|
+
data: [incomeVal, expenseVal],
|
|
98
|
+
// chartjs-plugin-datalabels config — typed via plugin module augmentation
|
|
99
|
+
datalabels: {
|
|
100
|
+
labels: {
|
|
101
|
+
value: {
|
|
102
|
+
anchor: "end",
|
|
103
|
+
align: "end",
|
|
104
|
+
offset: 10,
|
|
105
|
+
clamp: false,
|
|
106
|
+
font: { weight: "bold", size: 14 },
|
|
107
|
+
color: VALUE_LABEL_COLOR,
|
|
108
|
+
textAlign: "left",
|
|
109
|
+
// Returns array for multi-line: dollar value on line 1, blank on line 2
|
|
110
|
+
formatter: (v: number) => [formatDollar(v), ""],
|
|
111
|
+
},
|
|
112
|
+
name: {
|
|
113
|
+
anchor: "end",
|
|
114
|
+
align: "end",
|
|
115
|
+
offset: 10,
|
|
116
|
+
clamp: false,
|
|
117
|
+
font: { size: 12 },
|
|
118
|
+
color: FALLBACK_TICK,
|
|
119
|
+
textAlign: "left",
|
|
120
|
+
// Returns array for multi-line: blank on line 1, bar label on line 2
|
|
121
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
122
|
+
formatter: (_: number, ctx: any) =>
|
|
123
|
+
["", String(ctx.chart.data.labels?.[ctx.dataIndex] ?? "")],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
} as unknown as never,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}, [hasData, incomeVal, expenseVal, brandPrimary, brandSecondary]);
|
|
131
|
+
|
|
132
|
+
const options = useMemo<ChartOptions<"bar">>(() => ({
|
|
133
|
+
indexAxis: "y",
|
|
134
|
+
responsive: true,
|
|
135
|
+
maintainAspectRatio: false,
|
|
136
|
+
animation: { duration: 800, easing: "easeOutQuart" },
|
|
137
|
+
layout: {
|
|
138
|
+
// Right padding reserves space for the datalabels rendered outside the bar area
|
|
139
|
+
padding: { right: 180, left: 0, top: 10, bottom: 10 },
|
|
140
|
+
},
|
|
141
|
+
plugins: {
|
|
142
|
+
legend: { display: false },
|
|
143
|
+
tooltip: { enabled: false },
|
|
144
|
+
},
|
|
145
|
+
scales: {
|
|
146
|
+
y: {
|
|
147
|
+
display: true,
|
|
148
|
+
grid: { display: false },
|
|
149
|
+
border: { display: false },
|
|
150
|
+
ticks: { display: false },
|
|
151
|
+
},
|
|
152
|
+
x: {
|
|
153
|
+
display: true,
|
|
154
|
+
suggestedMax: maxVal * 1.3,
|
|
155
|
+
grid: { display: false },
|
|
156
|
+
border: { display: false },
|
|
157
|
+
ticks: { display: false },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}), [maxVal]);
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Card
|
|
164
|
+
className={cn("w-full py-4 sm:py-6 gap-2", className)}
|
|
165
|
+
style={{ maxWidth: width }}
|
|
166
|
+
>
|
|
167
|
+
<CardHeader className="px-3 sm:px-6">
|
|
168
|
+
<CardTitle className="text-sm sm:text-base">{title}</CardTitle>
|
|
169
|
+
</CardHeader>
|
|
170
|
+
|
|
171
|
+
<CardContent className="px-3 sm:px-6">
|
|
172
|
+
{isLoading ? (
|
|
173
|
+
<div
|
|
174
|
+
className="flex items-center justify-center text-muted-foreground"
|
|
175
|
+
style={{ height, width: "100%" }}
|
|
176
|
+
>
|
|
177
|
+
<Spinner size="lg" />
|
|
178
|
+
</div>
|
|
179
|
+
) : !hasData ? (
|
|
180
|
+
<Empty className="flex-none p-4" style={{ height }}>
|
|
181
|
+
<EmptyDescription>No data available</EmptyDescription>
|
|
182
|
+
</Empty>
|
|
183
|
+
) : (
|
|
184
|
+
<div style={{ height, width: "100%", position: "relative" }}>
|
|
185
|
+
<Chart
|
|
186
|
+
key={`${brandPrimary}__${brandSecondary}`}
|
|
187
|
+
type="bar"
|
|
188
|
+
data={chartData}
|
|
189
|
+
options={options}
|
|
190
|
+
plugins={[ChartDataLabels]}
|
|
191
|
+
aria-label={title}
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
</CardContent>
|
|
196
|
+
</Card>
|
|
197
|
+
);
|
|
198
|
+
}
|