@hed-hog/catalog 0.0.279 → 0.0.285

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.
@@ -1,562 +1,562 @@
1
- 'use client';
2
- import {
3
- catalogKpiResources,
4
- catalogQuickActionResources,
5
- catalogResources,
6
- } from '../_lib/catalog-resources';
7
- import { Page, PageHeader } from '@/components/entity-list';
8
- import { Badge } from '@/components/ui/badge';
9
- import { Button } from '@/components/ui/button';
10
- import {
11
- Card,
12
- CardContent,
13
- CardDescription,
14
- CardHeader,
15
- CardTitle,
16
- } from '@/components/ui/card';
17
- import { Skeleton } from '@/components/ui/skeleton';
18
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
19
- import {
20
- Activity,
21
- ArrowUpRight,
22
- Boxes,
23
- ChartNoAxesCombined,
24
- RefreshCcw,
25
- ShieldCheck,
26
- Sparkles,
27
- } from 'lucide-react';
28
- import Link from 'next/link';
29
- import { useTranslations } from 'next-intl';
30
- import {
31
- Bar,
32
- BarChart,
33
- CartesianGrid,
34
- Cell,
35
- Pie,
36
- PieChart,
37
- ResponsiveContainer,
38
- Tooltip,
39
- XAxis,
40
- YAxis,
41
- } from 'recharts';
42
-
43
- type CatalogStats = {
44
- resource: string;
45
- total: number;
46
- active?: number;
47
- };
48
-
49
- const chartPalette = [
50
- '#f97316',
51
- '#14b8a6',
52
- '#0ea5e9',
53
- '#84cc16',
54
- '#f59e0b',
55
- '#ef4444',
56
- ];
57
-
58
- const chartTooltipStyle = {
59
- backgroundColor: 'hsl(var(--card))',
60
- border: '1px solid hsl(var(--border))',
61
- borderRadius: '12px',
62
- fontSize: '12px',
63
- };
64
-
65
- export default function CatalogDashboardPage() {
66
- const t = useTranslations('catalog');
67
- const { accessToken, currentLocaleCode, request } = useApp();
68
- const isAppReady = accessToken.trim().length > 0;
69
-
70
- const { data, isLoading, refetch } = useQuery<CatalogStats[]>({
71
- queryKey: ['catalog-dashboard-stats', accessToken, currentLocaleCode],
72
- queryFn: async () => {
73
- const responses = await Promise.allSettled(
74
- catalogResources.map(async (resource) => {
75
- const response = await request({
76
- url: `/catalog/${resource.resource}/stats`,
77
- });
78
- const statsData = response.data as {
79
- total?: number;
80
- active?: number;
81
- };
82
-
83
- return {
84
- resource: resource.resource,
85
- total: Number(statsData.total ?? 0),
86
- active:
87
- statsData.active !== undefined
88
- ? Number(statsData.active)
89
- : undefined,
90
- } satisfies CatalogStats;
91
- })
92
- );
93
-
94
- return responses.map((response, index) =>
95
- response.status === 'fulfilled'
96
- ? response.value
97
- : {
98
- resource:
99
- catalogResources[index]?.resource ?? `fallback-${index}`,
100
- total: 0,
101
- }
102
- );
103
- },
104
- enabled: isAppReady,
105
- });
106
- const dashboardStats = (data ?? []) as CatalogStats[];
107
-
108
- const statsMap = new Map(dashboardStats.map((item) => [item.resource, item]));
109
- const totalRecords = dashboardStats.reduce(
110
- (sum, item) => sum + item.total,
111
- 0
112
- );
113
- const totalActive = dashboardStats.reduce(
114
- (sum, item) => sum + (typeof item.active === 'number' ? item.active : 0),
115
- 0
116
- );
117
- const monitoredResources = dashboardStats.filter(
118
- (item) => item.total > 0
119
- ).length;
120
- const statusAwareResources = dashboardStats.filter(
121
- (item) => typeof item.active === 'number'
122
- ).length;
123
-
124
- const kpiCards = catalogKpiResources.map((resourceKey) => {
125
- const resource = catalogResources.find(
126
- (item) => item.resource === resourceKey
127
- )!;
128
- const stats = statsMap.get(resourceKey);
129
- return { resource, total: stats?.total ?? 0, active: stats?.active };
130
- });
131
-
132
- const volumeChartData = [...catalogResources]
133
- .map((resource) => ({
134
- name: t(`resources.${resource.translationKey}.shortTitle`),
135
- total: statsMap.get(resource.resource)?.total ?? 0,
136
- }))
137
- .sort((left, right) => right.total - left.total)
138
- .slice(0, 6);
139
-
140
- const statusChartData = catalogResources
141
- .filter((resource) => resource.hasActiveStats)
142
- .map((resource) => {
143
- const stats = statsMap.get(resource.resource);
144
- const active = Number(stats?.active ?? 0);
145
- const total = Number(stats?.total ?? 0);
146
-
147
- return {
148
- name: t(`resources.${resource.translationKey}.shortTitle`),
149
- active,
150
- inactive: Math.max(total - active, 0),
151
- };
152
- });
153
-
154
- const distributionChartData = volumeChartData.map((item, index) => ({
155
- ...item,
156
- fill: chartPalette[index % chartPalette.length],
157
- }));
158
-
159
- const leaderboard = [...catalogResources]
160
- .map((resource) => ({
161
- resource,
162
- total: statsMap.get(resource.resource)?.total ?? 0,
163
- active: statsMap.get(resource.resource)?.active,
164
- }))
165
- .sort((left, right) => right.total - left.total)
166
- .slice(0, 5);
167
-
168
- return (
169
- <Page>
170
- <PageHeader
171
- title={t('dashboard.title')}
172
- description={t('dashboard.subtitle')}
173
- breadcrumbs={[
174
- { label: t('breadcrumbs.home'), href: '/' },
175
- { label: t('breadcrumbs.catalog') },
176
- ]}
177
- actions={[
178
- {
179
- label: t('refresh'),
180
- onClick: () => {
181
- void refetch();
182
- },
183
- variant: 'outline',
184
- icon: <RefreshCcw className="size-4" />,
185
- },
186
- ]}
187
- />
188
-
189
- <div className="min-w-0 space-y-6 overflow-x-hidden">
190
- <Card className="overflow-hidden border-orange-200/70 bg-gradient-to-br from-orange-50 via-background to-amber-50 py-0">
191
- <CardContent className="grid min-w-0 gap-6 px-6 py-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.9fr)]">
192
- <div className="min-w-0 space-y-4">
193
- <Badge className="w-fit rounded-full bg-orange-500/10 px-3 py-1 text-orange-700 hover:bg-orange-500/10">
194
- <Sparkles className="mr-2 size-3.5" />
195
- {t('dashboard.heroBadge')}
196
- </Badge>
197
- <div className="space-y-2">
198
- <h2 className="text-3xl font-semibold tracking-tight text-balance">
199
- {t('dashboard.heroTitle')}
200
- </h2>
201
- <p className="max-w-2xl text-sm leading-6 text-muted-foreground">
202
- {t('dashboard.heroDescription')}
203
- </p>
204
- </div>
205
- <div className="flex flex-wrap gap-3">
206
- <Button asChild>
207
- <Link href="/catalog/products">
208
- {t('dashboard.primaryAction')}
209
- </Link>
210
- </Button>
211
- <Button asChild variant="outline">
212
- <Link href="/catalog/import-sources">
213
- {t('dashboard.secondaryAction')}
214
- </Link>
215
- </Button>
216
- </div>
217
- </div>
218
-
219
- <div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
220
- {[
221
- {
222
- icon: Boxes,
223
- label: t('dashboard.summary.totalRecords'),
224
- value: totalRecords,
225
- },
226
- {
227
- icon: ShieldCheck,
228
- label: t('dashboard.summary.activeRecords'),
229
- value: totalActive,
230
- },
231
- {
232
- icon: Activity,
233
- label: t('dashboard.summary.monitoredResources'),
234
- value: monitoredResources,
235
- },
236
- ].map((item) => (
237
- <div
238
- key={item.label}
239
- className="rounded-2xl border border-white/70 bg-white/80 p-4 shadow-sm backdrop-blur"
240
- >
241
- <div className="mb-3 flex items-center gap-2 text-muted-foreground">
242
- <item.icon className="size-4" />
243
- <span className="text-xs uppercase tracking-[0.2em]">
244
- {item.label}
245
- </span>
246
- </div>
247
- <div className="text-3xl font-semibold tracking-tight">
248
- {item.value}
249
- </div>
250
- </div>
251
- ))}
252
- </div>
253
- </CardContent>
254
- </Card>
255
-
256
- <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
257
- {kpiCards.map(({ resource, total, active }) => {
258
- const Icon = resource.icon;
259
-
260
- return (
261
- <Card
262
- key={resource.resource}
263
- className="overflow-hidden border-border/70 py-0"
264
- >
265
- <div
266
- className={`h-1 w-full bg-gradient-to-r ${resource.colorClass}`}
267
- />
268
- <CardContent className="space-y-4 px-6 py-5">
269
- <div className="flex items-start justify-between gap-3">
270
- <div>
271
- <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
272
- {t(`resources.${resource.translationKey}.shortTitle`)}
273
- </p>
274
- <p className="mt-2 text-3xl font-semibold tracking-tight">
275
- {isLoading ? '-' : total}
276
- </p>
277
- </div>
278
- <div className={`rounded-2xl p-3 ${resource.glowClass}`}>
279
- <Icon className="size-5" />
280
- </div>
281
- </div>
282
- <div className="flex items-center justify-between text-sm text-muted-foreground">
283
- <span>{t('dashboard.card.totalLabel')}</span>
284
- <span>
285
- {typeof active === 'number'
286
- ? t('dashboard.card.activeLabel', { count: active })
287
- : t('dashboard.card.noActiveLabel')}
288
- </span>
289
- </div>
290
- </CardContent>
291
- </Card>
292
- );
293
- })}
294
- </div>
295
-
296
- <div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.9fr)]">
297
- <Card className="min-w-0">
298
- <CardHeader>
299
- <CardTitle>{t('dashboard.charts.volume.title')}</CardTitle>
300
- <CardDescription>
301
- {t('dashboard.charts.volume.description')}
302
- </CardDescription>
303
- </CardHeader>
304
- <CardContent className="h-[320px]">
305
- {isLoading ? (
306
- <Skeleton className="h-full w-full rounded-xl" />
307
- ) : (
308
- <ResponsiveContainer width="100%" height="100%">
309
- <BarChart data={volumeChartData}>
310
- <CartesianGrid
311
- strokeDasharray="3 3"
312
- stroke="hsl(var(--border))"
313
- vertical={false}
314
- />
315
- <XAxis
316
- dataKey="name"
317
- tickLine={false}
318
- axisLine={false}
319
- fontSize={12}
320
- />
321
- <YAxis tickLine={false} axisLine={false} fontSize={12} />
322
- <Tooltip contentStyle={chartTooltipStyle} />
323
- <Bar dataKey="total" radius={[8, 8, 0, 0]}>
324
- {volumeChartData.map((entry, index) => (
325
- <Cell
326
- key={`${entry.name}-${index}`}
327
- fill={chartPalette[index % chartPalette.length]}
328
- />
329
- ))}
330
- </Bar>
331
- </BarChart>
332
- </ResponsiveContainer>
333
- )}
334
- </CardContent>
335
- </Card>
336
-
337
- <Card className="min-w-0">
338
- <CardHeader>
339
- <CardTitle>{t('dashboard.ranking.title')}</CardTitle>
340
- <CardDescription>
341
- {t('dashboard.ranking.description')}
342
- </CardDescription>
343
- </CardHeader>
344
- <CardContent className="space-y-3">
345
- {isLoading
346
- ? Array.from({ length: 5 }).map((_, index) => (
347
- <Skeleton
348
- key={index}
349
- className="h-[72px] w-full rounded-2xl"
350
- />
351
- ))
352
- : leaderboard.map((item, index) => {
353
- const Icon = item.resource.icon;
354
-
355
- return (
356
- <Link
357
- key={item.resource.resource}
358
- href={item.resource.href}
359
- className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 transition-colors hover:bg-muted/40"
360
- >
361
- <div className="flex min-w-0 items-center gap-3">
362
- <div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
363
- <Icon className="size-4" />
364
- </div>
365
- <div className="min-w-0">
366
- <p className="truncate font-medium">
367
- {index + 1}.{' '}
368
- {t(
369
- `resources.${item.resource.translationKey}.title`
370
- )}
371
- </p>
372
- <p className="truncate text-xs text-muted-foreground">
373
- {typeof item.active === 'number'
374
- ? t('dashboard.ranking.activeCount', {
375
- count: item.active,
376
- })
377
- : t('dashboard.card.noActiveLabel')}
378
- </p>
379
- </div>
380
- </div>
381
- <div className="shrink-0 text-right">
382
- <p className="text-2xl font-semibold">{item.total}</p>
383
- <p className="text-xs text-muted-foreground">
384
- {t('dashboard.card.totalLabel')}
385
- </p>
386
- </div>
387
- </Link>
388
- );
389
- })}
390
- </CardContent>
391
- </Card>
392
- </div>
393
-
394
- <div className="grid gap-6 xl:grid-cols-2">
395
- <Card className="min-w-0">
396
- <CardHeader>
397
- <CardTitle>{t('dashboard.charts.status.title')}</CardTitle>
398
- <CardDescription>
399
- {t('dashboard.charts.status.description', {
400
- count: statusAwareResources,
401
- })}
402
- </CardDescription>
403
- </CardHeader>
404
- <CardContent className="h-[320px]">
405
- {isLoading ? (
406
- <Skeleton className="h-full w-full rounded-xl" />
407
- ) : (
408
- <ResponsiveContainer width="100%" height="100%">
409
- <BarChart data={statusChartData}>
410
- <CartesianGrid
411
- strokeDasharray="3 3"
412
- stroke="hsl(var(--border))"
413
- vertical={false}
414
- />
415
- <XAxis
416
- dataKey="name"
417
- tickLine={false}
418
- axisLine={false}
419
- fontSize={12}
420
- />
421
- <YAxis tickLine={false} axisLine={false} fontSize={12} />
422
- <Tooltip contentStyle={chartTooltipStyle} />
423
- <Bar
424
- dataKey="active"
425
- stackId="catalog-status"
426
- fill="#f97316"
427
- radius={[8, 8, 0, 0]}
428
- />
429
- <Bar
430
- dataKey="inactive"
431
- stackId="catalog-status"
432
- fill="#fed7aa"
433
- radius={[8, 8, 0, 0]}
434
- />
435
- </BarChart>
436
- </ResponsiveContainer>
437
- )}
438
- </CardContent>
439
- </Card>
440
-
441
- <Card className="min-w-0">
442
- <CardHeader>
443
- <CardTitle>{t('dashboard.charts.distribution.title')}</CardTitle>
444
- <CardDescription>
445
- {t('dashboard.charts.distribution.description')}
446
- </CardDescription>
447
- </CardHeader>
448
- <CardContent className="grid min-w-0 h-auto gap-4 lg:h-[320px] lg:grid-cols-[minmax(220px,0.9fr)_minmax(0,1.1fr)]">
449
- {isLoading ? (
450
- <>
451
- <Skeleton className="h-full w-full rounded-full" />
452
- <Skeleton className="h-full w-full rounded-2xl" />
453
- </>
454
- ) : (
455
- <>
456
- <ResponsiveContainer width="100%" height="100%">
457
- <PieChart>
458
- <Pie
459
- data={distributionChartData}
460
- dataKey="total"
461
- nameKey="name"
462
- innerRadius={58}
463
- outerRadius={96}
464
- paddingAngle={2}
465
- >
466
- {distributionChartData.map((entry) => (
467
- <Cell key={entry.name} fill={entry.fill} />
468
- ))}
469
- </Pie>
470
- <Tooltip contentStyle={chartTooltipStyle} />
471
- </PieChart>
472
- </ResponsiveContainer>
473
- <div className="min-w-0 space-y-3">
474
- {distributionChartData.map((item) => (
475
- <div
476
- key={item.name}
477
- className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3"
478
- >
479
- <div className="flex min-w-0 items-center gap-3">
480
- <span
481
- className="inline-block size-3 rounded-full"
482
- style={{ backgroundColor: item.fill }}
483
- />
484
- <span className="truncate font-medium">{item.name}</span>
485
- </div>
486
- <span className="shrink-0 text-sm text-muted-foreground">
487
- {item.total}
488
- </span>
489
- </div>
490
- ))}
491
- </div>
492
- </>
493
- )}
494
- </CardContent>
495
- </Card>
496
- </div>
497
-
498
- <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
499
- {catalogQuickActionResources.map((resource) => {
500
- const Icon = resource.icon;
501
-
502
- return (
503
- <Card key={resource.resource} className="min-w-0 overflow-hidden py-0">
504
- <div
505
- className={`h-full bg-gradient-to-br ${resource.colorClass} px-6 py-5`}
506
- >
507
- <div className="min-w-0 rounded-2xl border border-white/70 bg-background/90 p-5 shadow-sm backdrop-blur">
508
- <div className="mb-4 flex items-center justify-between">
509
- <div className={`rounded-2xl p-3 ${resource.glowClass}`}>
510
- <Icon className="size-5" />
511
- </div>
512
- <ArrowUpRight className="size-4 text-muted-foreground" />
513
- </div>
514
- <h3 className="text-lg font-semibold">
515
- {t(`resources.${resource.translationKey}.title`)}
516
- </h3>
517
- <p className="mt-2 min-h-10 break-words text-sm text-muted-foreground">
518
- {t(`resources.${resource.translationKey}.description`)}
519
- </p>
520
- <div className="mt-5 flex items-center justify-between">
521
- <span className="text-sm font-medium text-muted-foreground">
522
- {t('dashboard.quickActions.itemHint')}
523
- </span>
524
- <Button asChild size="sm">
525
- <Link href={resource.href}>{t('openResource')}</Link>
526
- </Button>
527
- </div>
528
- </div>
529
- </div>
530
- </Card>
531
- );
532
- })}
533
- </div>
534
-
535
- <Card className="min-w-0 border-dashed">
536
- <CardContent className="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
537
- <div className="space-y-1">
538
- <div className="flex items-center gap-2 text-sm font-medium">
539
- <ChartNoAxesCombined className="size-4 text-orange-600" />
540
- {t('dashboard.footer.title')}
541
- </div>
542
- <p className="text-sm text-muted-foreground">
543
- {t('dashboard.footer.description')}
544
- </p>
545
- </div>
546
- <div className="flex flex-wrap gap-2">
547
- <Badge variant="secondary">
548
- {t('dashboard.footer.realData')}
549
- </Badge>
550
- <Badge variant="outline">{t('dashboard.footer.multiView')}</Badge>
551
- <Badge variant="outline">
552
- {t('dashboard.footer.resourceCount', {
553
- count: catalogResources.length,
554
- })}
555
- </Badge>
556
- </div>
557
- </CardContent>
558
- </Card>
559
- </div>
560
- </Page>
561
- );
562
- }
1
+ 'use client';
2
+ import {
3
+ catalogKpiResources,
4
+ catalogQuickActionResources,
5
+ catalogResources,
6
+ } from '../_lib/catalog-resources';
7
+ import { Page, PageHeader } from '@/components/entity-list';
8
+ import { Badge } from '@/components/ui/badge';
9
+ import { Button } from '@/components/ui/button';
10
+ import {
11
+ Card,
12
+ CardContent,
13
+ CardDescription,
14
+ CardHeader,
15
+ CardTitle,
16
+ } from '@/components/ui/card';
17
+ import { Skeleton } from '@/components/ui/skeleton';
18
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
19
+ import {
20
+ Activity,
21
+ ArrowUpRight,
22
+ Boxes,
23
+ ChartNoAxesCombined,
24
+ RefreshCcw,
25
+ ShieldCheck,
26
+ Sparkles,
27
+ } from 'lucide-react';
28
+ import Link from 'next/link';
29
+ import { useTranslations } from 'next-intl';
30
+ import {
31
+ Bar,
32
+ BarChart,
33
+ CartesianGrid,
34
+ Cell,
35
+ Pie,
36
+ PieChart,
37
+ ResponsiveContainer,
38
+ Tooltip,
39
+ XAxis,
40
+ YAxis,
41
+ } from 'recharts';
42
+
43
+ type CatalogStats = {
44
+ resource: string;
45
+ total: number;
46
+ active?: number;
47
+ };
48
+
49
+ const chartPalette = [
50
+ '#f97316',
51
+ '#14b8a6',
52
+ '#0ea5e9',
53
+ '#84cc16',
54
+ '#f59e0b',
55
+ '#ef4444',
56
+ ];
57
+
58
+ const chartTooltipStyle = {
59
+ backgroundColor: 'hsl(var(--card))',
60
+ border: '1px solid hsl(var(--border))',
61
+ borderRadius: '12px',
62
+ fontSize: '12px',
63
+ };
64
+
65
+ export default function CatalogDashboardPage() {
66
+ const t = useTranslations('catalog');
67
+ const { accessToken, currentLocaleCode, request } = useApp();
68
+ const isAppReady = accessToken.trim().length > 0;
69
+
70
+ const { data, isLoading, refetch } = useQuery<CatalogStats[]>({
71
+ queryKey: ['catalog-dashboard-stats', accessToken, currentLocaleCode],
72
+ queryFn: async () => {
73
+ const responses = await Promise.allSettled(
74
+ catalogResources.map(async (resource) => {
75
+ const response = await request({
76
+ url: `/catalog/${resource.resource}/stats`,
77
+ });
78
+ const statsData = response.data as {
79
+ total?: number;
80
+ active?: number;
81
+ };
82
+
83
+ return {
84
+ resource: resource.resource,
85
+ total: Number(statsData.total ?? 0),
86
+ active:
87
+ statsData.active !== undefined
88
+ ? Number(statsData.active)
89
+ : undefined,
90
+ } satisfies CatalogStats;
91
+ })
92
+ );
93
+
94
+ return responses.map((response, index) =>
95
+ response.status === 'fulfilled'
96
+ ? response.value
97
+ : {
98
+ resource:
99
+ catalogResources[index]?.resource ?? `fallback-${index}`,
100
+ total: 0,
101
+ }
102
+ );
103
+ },
104
+ enabled: isAppReady,
105
+ });
106
+ const dashboardStats = (data ?? []) as CatalogStats[];
107
+
108
+ const statsMap = new Map(dashboardStats.map((item) => [item.resource, item]));
109
+ const totalRecords = dashboardStats.reduce(
110
+ (sum, item) => sum + item.total,
111
+ 0
112
+ );
113
+ const totalActive = dashboardStats.reduce(
114
+ (sum, item) => sum + (typeof item.active === 'number' ? item.active : 0),
115
+ 0
116
+ );
117
+ const monitoredResources = dashboardStats.filter(
118
+ (item) => item.total > 0
119
+ ).length;
120
+ const statusAwareResources = dashboardStats.filter(
121
+ (item) => typeof item.active === 'number'
122
+ ).length;
123
+
124
+ const kpiCards = catalogKpiResources.map((resourceKey) => {
125
+ const resource = catalogResources.find(
126
+ (item) => item.resource === resourceKey
127
+ )!;
128
+ const stats = statsMap.get(resourceKey);
129
+ return { resource, total: stats?.total ?? 0, active: stats?.active };
130
+ });
131
+
132
+ const volumeChartData = [...catalogResources]
133
+ .map((resource) => ({
134
+ name: t(`resources.${resource.translationKey}.shortTitle`),
135
+ total: statsMap.get(resource.resource)?.total ?? 0,
136
+ }))
137
+ .sort((left, right) => right.total - left.total)
138
+ .slice(0, 6);
139
+
140
+ const statusChartData = catalogResources
141
+ .filter((resource) => resource.hasActiveStats)
142
+ .map((resource) => {
143
+ const stats = statsMap.get(resource.resource);
144
+ const active = Number(stats?.active ?? 0);
145
+ const total = Number(stats?.total ?? 0);
146
+
147
+ return {
148
+ name: t(`resources.${resource.translationKey}.shortTitle`),
149
+ active,
150
+ inactive: Math.max(total - active, 0),
151
+ };
152
+ });
153
+
154
+ const distributionChartData = volumeChartData.map((item, index) => ({
155
+ ...item,
156
+ fill: chartPalette[index % chartPalette.length],
157
+ }));
158
+
159
+ const leaderboard = [...catalogResources]
160
+ .map((resource) => ({
161
+ resource,
162
+ total: statsMap.get(resource.resource)?.total ?? 0,
163
+ active: statsMap.get(resource.resource)?.active,
164
+ }))
165
+ .sort((left, right) => right.total - left.total)
166
+ .slice(0, 5);
167
+
168
+ return (
169
+ <Page>
170
+ <PageHeader
171
+ title={t('dashboard.title')}
172
+ description={t('dashboard.subtitle')}
173
+ breadcrumbs={[
174
+ { label: t('breadcrumbs.home'), href: '/' },
175
+ { label: t('breadcrumbs.catalog') },
176
+ ]}
177
+ actions={[
178
+ {
179
+ label: t('refresh'),
180
+ onClick: () => {
181
+ void refetch();
182
+ },
183
+ variant: 'outline',
184
+ icon: <RefreshCcw className="size-4" />,
185
+ },
186
+ ]}
187
+ />
188
+
189
+ <div className="min-w-0 space-y-6 overflow-x-hidden">
190
+ <Card className="overflow-hidden border-orange-200/70 bg-gradient-to-br from-orange-50 via-background to-amber-50 py-0">
191
+ <CardContent className="grid min-w-0 gap-6 px-6 py-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.9fr)]">
192
+ <div className="min-w-0 space-y-4">
193
+ <Badge className="w-fit rounded-full bg-orange-500/10 px-3 py-1 text-orange-700 hover:bg-orange-500/10">
194
+ <Sparkles className="mr-2 size-3.5" />
195
+ {t('dashboard.heroBadge')}
196
+ </Badge>
197
+ <div className="space-y-2">
198
+ <h2 className="text-3xl font-semibold tracking-tight text-balance">
199
+ {t('dashboard.heroTitle')}
200
+ </h2>
201
+ <p className="max-w-2xl text-sm leading-6 text-muted-foreground">
202
+ {t('dashboard.heroDescription')}
203
+ </p>
204
+ </div>
205
+ <div className="flex flex-wrap gap-3">
206
+ <Button asChild>
207
+ <Link href="/catalog/products">
208
+ {t('dashboard.primaryAction')}
209
+ </Link>
210
+ </Button>
211
+ <Button asChild variant="outline">
212
+ <Link href="/catalog/import-sources">
213
+ {t('dashboard.secondaryAction')}
214
+ </Link>
215
+ </Button>
216
+ </div>
217
+ </div>
218
+
219
+ <div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
220
+ {[
221
+ {
222
+ icon: Boxes,
223
+ label: t('dashboard.summary.totalRecords'),
224
+ value: totalRecords,
225
+ },
226
+ {
227
+ icon: ShieldCheck,
228
+ label: t('dashboard.summary.activeRecords'),
229
+ value: totalActive,
230
+ },
231
+ {
232
+ icon: Activity,
233
+ label: t('dashboard.summary.monitoredResources'),
234
+ value: monitoredResources,
235
+ },
236
+ ].map((item) => (
237
+ <div
238
+ key={item.label}
239
+ className="rounded-2xl border border-white/70 bg-white/80 p-4 shadow-sm backdrop-blur"
240
+ >
241
+ <div className="mb-3 flex items-center gap-2 text-muted-foreground">
242
+ <item.icon className="size-4" />
243
+ <span className="text-xs uppercase tracking-[0.2em]">
244
+ {item.label}
245
+ </span>
246
+ </div>
247
+ <div className="text-3xl font-semibold tracking-tight">
248
+ {item.value}
249
+ </div>
250
+ </div>
251
+ ))}
252
+ </div>
253
+ </CardContent>
254
+ </Card>
255
+
256
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
257
+ {kpiCards.map(({ resource, total, active }) => {
258
+ const Icon = resource.icon;
259
+
260
+ return (
261
+ <Card
262
+ key={resource.resource}
263
+ className="overflow-hidden border-border/70 py-0"
264
+ >
265
+ <div
266
+ className={`h-1 w-full bg-gradient-to-r ${resource.colorClass}`}
267
+ />
268
+ <CardContent className="space-y-4 px-6 py-5">
269
+ <div className="flex items-start justify-between gap-3">
270
+ <div>
271
+ <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
272
+ {t(`resources.${resource.translationKey}.shortTitle`)}
273
+ </p>
274
+ <p className="mt-2 text-3xl font-semibold tracking-tight">
275
+ {isLoading ? '-' : total}
276
+ </p>
277
+ </div>
278
+ <div className={`rounded-2xl p-3 ${resource.glowClass}`}>
279
+ <Icon className="size-5" />
280
+ </div>
281
+ </div>
282
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
283
+ <span>{t('dashboard.card.totalLabel')}</span>
284
+ <span>
285
+ {typeof active === 'number'
286
+ ? t('dashboard.card.activeLabel', { count: active })
287
+ : t('dashboard.card.noActiveLabel')}
288
+ </span>
289
+ </div>
290
+ </CardContent>
291
+ </Card>
292
+ );
293
+ })}
294
+ </div>
295
+
296
+ <div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.9fr)]">
297
+ <Card className="min-w-0">
298
+ <CardHeader>
299
+ <CardTitle>{t('dashboard.charts.volume.title')}</CardTitle>
300
+ <CardDescription>
301
+ {t('dashboard.charts.volume.description')}
302
+ </CardDescription>
303
+ </CardHeader>
304
+ <CardContent className="h-[320px]">
305
+ {isLoading ? (
306
+ <Skeleton className="h-full w-full rounded-xl" />
307
+ ) : (
308
+ <ResponsiveContainer width="100%" height="100%">
309
+ <BarChart data={volumeChartData}>
310
+ <CartesianGrid
311
+ strokeDasharray="3 3"
312
+ stroke="hsl(var(--border))"
313
+ vertical={false}
314
+ />
315
+ <XAxis
316
+ dataKey="name"
317
+ tickLine={false}
318
+ axisLine={false}
319
+ fontSize={12}
320
+ />
321
+ <YAxis tickLine={false} axisLine={false} fontSize={12} />
322
+ <Tooltip contentStyle={chartTooltipStyle} />
323
+ <Bar dataKey="total" radius={[8, 8, 0, 0]}>
324
+ {volumeChartData.map((entry, index) => (
325
+ <Cell
326
+ key={`${entry.name}-${index}`}
327
+ fill={chartPalette[index % chartPalette.length]}
328
+ />
329
+ ))}
330
+ </Bar>
331
+ </BarChart>
332
+ </ResponsiveContainer>
333
+ )}
334
+ </CardContent>
335
+ </Card>
336
+
337
+ <Card className="min-w-0">
338
+ <CardHeader>
339
+ <CardTitle>{t('dashboard.ranking.title')}</CardTitle>
340
+ <CardDescription>
341
+ {t('dashboard.ranking.description')}
342
+ </CardDescription>
343
+ </CardHeader>
344
+ <CardContent className="space-y-3">
345
+ {isLoading
346
+ ? Array.from({ length: 5 }).map((_, index) => (
347
+ <Skeleton
348
+ key={index}
349
+ className="h-[72px] w-full rounded-2xl"
350
+ />
351
+ ))
352
+ : leaderboard.map((item, index) => {
353
+ const Icon = item.resource.icon;
354
+
355
+ return (
356
+ <Link
357
+ key={item.resource.resource}
358
+ href={item.resource.href}
359
+ className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 transition-colors hover:bg-muted/40"
360
+ >
361
+ <div className="flex min-w-0 items-center gap-3">
362
+ <div className="flex size-10 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
363
+ <Icon className="size-4" />
364
+ </div>
365
+ <div className="min-w-0">
366
+ <p className="truncate font-medium">
367
+ {index + 1}.{' '}
368
+ {t(
369
+ `resources.${item.resource.translationKey}.title`
370
+ )}
371
+ </p>
372
+ <p className="truncate text-xs text-muted-foreground">
373
+ {typeof item.active === 'number'
374
+ ? t('dashboard.ranking.activeCount', {
375
+ count: item.active,
376
+ })
377
+ : t('dashboard.card.noActiveLabel')}
378
+ </p>
379
+ </div>
380
+ </div>
381
+ <div className="shrink-0 text-right">
382
+ <p className="text-2xl font-semibold">{item.total}</p>
383
+ <p className="text-xs text-muted-foreground">
384
+ {t('dashboard.card.totalLabel')}
385
+ </p>
386
+ </div>
387
+ </Link>
388
+ );
389
+ })}
390
+ </CardContent>
391
+ </Card>
392
+ </div>
393
+
394
+ <div className="grid gap-6 xl:grid-cols-2">
395
+ <Card className="min-w-0">
396
+ <CardHeader>
397
+ <CardTitle>{t('dashboard.charts.status.title')}</CardTitle>
398
+ <CardDescription>
399
+ {t('dashboard.charts.status.description', {
400
+ count: statusAwareResources,
401
+ })}
402
+ </CardDescription>
403
+ </CardHeader>
404
+ <CardContent className="h-[320px]">
405
+ {isLoading ? (
406
+ <Skeleton className="h-full w-full rounded-xl" />
407
+ ) : (
408
+ <ResponsiveContainer width="100%" height="100%">
409
+ <BarChart data={statusChartData}>
410
+ <CartesianGrid
411
+ strokeDasharray="3 3"
412
+ stroke="hsl(var(--border))"
413
+ vertical={false}
414
+ />
415
+ <XAxis
416
+ dataKey="name"
417
+ tickLine={false}
418
+ axisLine={false}
419
+ fontSize={12}
420
+ />
421
+ <YAxis tickLine={false} axisLine={false} fontSize={12} />
422
+ <Tooltip contentStyle={chartTooltipStyle} />
423
+ <Bar
424
+ dataKey="active"
425
+ stackId="catalog-status"
426
+ fill="#f97316"
427
+ radius={[8, 8, 0, 0]}
428
+ />
429
+ <Bar
430
+ dataKey="inactive"
431
+ stackId="catalog-status"
432
+ fill="#fed7aa"
433
+ radius={[8, 8, 0, 0]}
434
+ />
435
+ </BarChart>
436
+ </ResponsiveContainer>
437
+ )}
438
+ </CardContent>
439
+ </Card>
440
+
441
+ <Card className="min-w-0">
442
+ <CardHeader>
443
+ <CardTitle>{t('dashboard.charts.distribution.title')}</CardTitle>
444
+ <CardDescription>
445
+ {t('dashboard.charts.distribution.description')}
446
+ </CardDescription>
447
+ </CardHeader>
448
+ <CardContent className="grid min-w-0 h-auto gap-4 lg:h-[320px] lg:grid-cols-[minmax(220px,0.9fr)_minmax(0,1.1fr)]">
449
+ {isLoading ? (
450
+ <>
451
+ <Skeleton className="h-full w-full rounded-full" />
452
+ <Skeleton className="h-full w-full rounded-2xl" />
453
+ </>
454
+ ) : (
455
+ <>
456
+ <ResponsiveContainer width="100%" height="100%">
457
+ <PieChart>
458
+ <Pie
459
+ data={distributionChartData}
460
+ dataKey="total"
461
+ nameKey="name"
462
+ innerRadius={58}
463
+ outerRadius={96}
464
+ paddingAngle={2}
465
+ >
466
+ {distributionChartData.map((entry) => (
467
+ <Cell key={entry.name} fill={entry.fill} />
468
+ ))}
469
+ </Pie>
470
+ <Tooltip contentStyle={chartTooltipStyle} />
471
+ </PieChart>
472
+ </ResponsiveContainer>
473
+ <div className="min-w-0 space-y-3">
474
+ {distributionChartData.map((item) => (
475
+ <div
476
+ key={item.name}
477
+ className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3"
478
+ >
479
+ <div className="flex min-w-0 items-center gap-3">
480
+ <span
481
+ className="inline-block size-3 rounded-full"
482
+ style={{ backgroundColor: item.fill }}
483
+ />
484
+ <span className="truncate font-medium">{item.name}</span>
485
+ </div>
486
+ <span className="shrink-0 text-sm text-muted-foreground">
487
+ {item.total}
488
+ </span>
489
+ </div>
490
+ ))}
491
+ </div>
492
+ </>
493
+ )}
494
+ </CardContent>
495
+ </Card>
496
+ </div>
497
+
498
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
499
+ {catalogQuickActionResources.map((resource) => {
500
+ const Icon = resource.icon;
501
+
502
+ return (
503
+ <Card key={resource.resource} className="min-w-0 overflow-hidden py-0">
504
+ <div
505
+ className={`h-full bg-gradient-to-br ${resource.colorClass} px-6 py-5`}
506
+ >
507
+ <div className="min-w-0 rounded-2xl border border-white/70 bg-background/90 p-5 shadow-sm backdrop-blur">
508
+ <div className="mb-4 flex items-center justify-between">
509
+ <div className={`rounded-2xl p-3 ${resource.glowClass}`}>
510
+ <Icon className="size-5" />
511
+ </div>
512
+ <ArrowUpRight className="size-4 text-muted-foreground" />
513
+ </div>
514
+ <h3 className="text-lg font-semibold">
515
+ {t(`resources.${resource.translationKey}.title`)}
516
+ </h3>
517
+ <p className="mt-2 min-h-10 break-words text-sm text-muted-foreground">
518
+ {t(`resources.${resource.translationKey}.description`)}
519
+ </p>
520
+ <div className="mt-5 flex items-center justify-between">
521
+ <span className="text-sm font-medium text-muted-foreground">
522
+ {t('dashboard.quickActions.itemHint')}
523
+ </span>
524
+ <Button asChild size="sm">
525
+ <Link href={resource.href}>{t('openResource')}</Link>
526
+ </Button>
527
+ </div>
528
+ </div>
529
+ </div>
530
+ </Card>
531
+ );
532
+ })}
533
+ </div>
534
+
535
+ <Card className="min-w-0 border-dashed">
536
+ <CardContent className="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
537
+ <div className="space-y-1">
538
+ <div className="flex items-center gap-2 text-sm font-medium">
539
+ <ChartNoAxesCombined className="size-4 text-orange-600" />
540
+ {t('dashboard.footer.title')}
541
+ </div>
542
+ <p className="text-sm text-muted-foreground">
543
+ {t('dashboard.footer.description')}
544
+ </p>
545
+ </div>
546
+ <div className="flex flex-wrap gap-2">
547
+ <Badge variant="secondary">
548
+ {t('dashboard.footer.realData')}
549
+ </Badge>
550
+ <Badge variant="outline">{t('dashboard.footer.multiView')}</Badge>
551
+ <Badge variant="outline">
552
+ {t('dashboard.footer.resourceCount', {
553
+ count: catalogResources.length,
554
+ })}
555
+ </Badge>
556
+ </div>
557
+ </CardContent>
558
+ </Card>
559
+ </div>
560
+ </Page>
561
+ );
562
+ }