create-quadrokit 0.2.11 → 0.2.13
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/package.json +1 -1
- package/templates/README.md +4 -4
- package/templates/admin-shell/src/components/layout/AppShell.tsx +55 -0
- package/templates/admin-shell/src/components/layout/LanguageSwitcher.tsx +37 -0
- package/templates/admin-shell/src/components/layout/NotificationsMenu.tsx +79 -0
- package/templates/admin-shell/src/components/layout/PageHeading.tsx +40 -0
- package/templates/admin-shell/src/components/layout/Sidebar.tsx +65 -0
- package/templates/admin-shell/src/components/layout/ThemeMenu.tsx +90 -0
- package/templates/admin-shell/src/components/layout/TopHeader.tsx +78 -0
- package/templates/admin-shell/src/i18n.ts +16 -2
- package/templates/admin-shell/src/locales/en.json +48 -1
- package/templates/admin-shell/src/locales/fr.json +62 -0
- package/templates/admin-shell/src/main.tsx +8 -2
- package/templates/admin-shell/src/pages/HomePage.tsx +1 -2
- package/templates/admin-shell/src/pages/SampleDataPage.tsx +37 -0
- package/templates/admin-shell/src/router.tsx +13 -4
- package/templates/admin-shell/src/types/router.ts +4 -0
- package/templates/dashboard/package.json +1 -0
- package/templates/dashboard/src/components/dashboard/ChartCard.tsx +29 -0
- package/templates/dashboard/src/components/dashboard/DashboardOverview.tsx +79 -0
- package/templates/dashboard/src/components/dashboard/KpiCard.tsx +31 -0
- package/templates/dashboard/src/components/dashboard/RegionsBarChart.tsx +45 -0
- package/templates/dashboard/src/components/dashboard/SeriesBarChart.tsx +55 -0
- package/templates/dashboard/src/components/dashboard/TrendLineChart.tsx +55 -0
- package/templates/dashboard/src/components/dashboard/chartTheme.ts +18 -0
- package/templates/dashboard/src/components/dashboard/dashboardMockData.ts +51 -0
- package/templates/dashboard/src/components/dashboard/index.ts +1 -0
- package/templates/dashboard/src/components/layout/AppShell.tsx +55 -0
- package/templates/dashboard/src/components/layout/LanguageSwitcher.tsx +37 -0
- package/templates/dashboard/src/components/layout/NotificationsMenu.tsx +79 -0
- package/templates/dashboard/src/components/layout/PageHeading.tsx +40 -0
- package/templates/dashboard/src/components/layout/Sidebar.tsx +65 -0
- package/templates/dashboard/src/components/layout/ThemeMenu.tsx +90 -0
- package/templates/dashboard/src/components/layout/TopHeader.tsx +78 -0
- package/templates/dashboard/src/i18n.ts +16 -2
- package/templates/dashboard/src/locales/en.json +66 -1
- package/templates/dashboard/src/locales/fr.json +80 -0
- package/templates/dashboard/src/main.tsx +8 -2
- package/templates/dashboard/src/pages/HomePage.tsx +17 -2
- package/templates/dashboard/src/pages/SampleDataPage.tsx +37 -0
- package/templates/dashboard/src/router.tsx +13 -4
- package/templates/dashboard/src/types/router.ts +4 -0
- package/templates/ecommerce/src/components/layout/AppShell.tsx +55 -0
- package/templates/ecommerce/src/components/layout/LanguageSwitcher.tsx +37 -0
- package/templates/ecommerce/src/components/layout/NotificationsMenu.tsx +79 -0
- package/templates/ecommerce/src/components/layout/PageHeading.tsx +40 -0
- package/templates/ecommerce/src/components/layout/Sidebar.tsx +65 -0
- package/templates/ecommerce/src/components/layout/ThemeMenu.tsx +90 -0
- package/templates/ecommerce/src/components/layout/TopHeader.tsx +78 -0
- package/templates/ecommerce/src/i18n.ts +16 -2
- package/templates/ecommerce/src/locales/en.json +43 -1
- package/templates/ecommerce/src/locales/fr.json +62 -0
- package/templates/ecommerce/src/main.tsx +8 -2
- package/templates/ecommerce/src/pages/HomePage.tsx +1 -2
- package/templates/ecommerce/src/pages/SampleDataPage.tsx +37 -0
- package/templates/ecommerce/src/router.tsx +13 -4
- package/templates/ecommerce/src/types/router.ts +4 -0
- package/templates/website/src/components/layout/AppShell.tsx +55 -0
- package/templates/website/src/components/layout/LanguageSwitcher.tsx +37 -0
- package/templates/website/src/components/layout/NotificationsMenu.tsx +79 -0
- package/templates/website/src/components/layout/PageHeading.tsx +40 -0
- package/templates/website/src/components/layout/Sidebar.tsx +65 -0
- package/templates/website/src/components/layout/ThemeMenu.tsx +90 -0
- package/templates/website/src/components/layout/TopHeader.tsx +78 -0
- package/templates/website/src/i18n.ts +16 -2
- package/templates/website/src/locales/en.json +43 -1
- package/templates/website/src/locales/fr.json +63 -0
- package/templates/website/src/main.tsx +8 -2
- package/templates/website/src/pages/HomePage.tsx +1 -4
- package/templates/website/src/pages/SampleDataPage.tsx +37 -0
- package/templates/website/src/router.tsx +13 -4
- package/templates/website/src/types/router.ts +4 -0
- package/templates/admin-shell/src/components/AppShell.tsx +0 -68
- package/templates/admin-shell/src/pages/AgenciesPage.tsx +0 -83
- package/templates/dashboard/src/components/AppShell.tsx +0 -44
- package/templates/dashboard/src/pages/AgenciesPage.tsx +0 -83
- package/templates/ecommerce/src/components/AppShell.tsx +0 -44
- package/templates/ecommerce/src/pages/AgenciesPage.tsx +0 -83
- package/templates/website/src/components/AppShell.tsx +0 -44
- package/templates/website/src/pages/AgenciesPage.tsx +0 -83
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@quadrokit/ui'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
|
|
4
|
+
/** Static placeholder — swap for `quadro.<YourEntity>.all(...)` wired to your catalog. */
|
|
5
|
+
const PLACEHOLDER_ROWS = [
|
|
6
|
+
{ id: '1', label: 'Example row A' },
|
|
7
|
+
{ id: '2', label: 'Example row B' },
|
|
8
|
+
{ id: '3', label: 'Example row C' },
|
|
9
|
+
] as const
|
|
10
|
+
|
|
11
|
+
export function SampleDataPage() {
|
|
12
|
+
const { t } = useTranslation()
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="space-y-6">
|
|
16
|
+
<div>
|
|
17
|
+
<p className="max-w-2xl text-sm text-muted-foreground">{t('sample_data.intro')}</p>
|
|
18
|
+
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{t('sample_data.hint')}</p>
|
|
19
|
+
</div>
|
|
20
|
+
<Card>
|
|
21
|
+
<CardHeader>
|
|
22
|
+
<CardTitle>{t('sample_data.card_title')}</CardTitle>
|
|
23
|
+
<CardDescription>
|
|
24
|
+
{t('sample_data.count', { count: PLACEHOLDER_ROWS.length })}
|
|
25
|
+
</CardDescription>
|
|
26
|
+
</CardHeader>
|
|
27
|
+
<CardContent>
|
|
28
|
+
<ul className="list-inside list-disc space-y-1 text-sm">
|
|
29
|
+
{PLACEHOLDER_ROWS.map((row) => (
|
|
30
|
+
<li key={row.id}>{row.label}</li>
|
|
31
|
+
))}
|
|
32
|
+
</ul>
|
|
33
|
+
</CardContent>
|
|
34
|
+
</Card>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { createBrowserRouter } from 'react-router-dom'
|
|
2
|
-
import { AppShell } from './components/AppShell'
|
|
3
|
-
import { AgenciesPage } from './pages/AgenciesPage'
|
|
2
|
+
import { AppShell } from './components/layout/AppShell'
|
|
4
3
|
import { HomePage } from './pages/HomePage'
|
|
4
|
+
import { SampleDataPage } from './pages/SampleDataPage'
|
|
5
|
+
import type { AppRouteHandle } from './types/router'
|
|
5
6
|
|
|
6
7
|
export const router = createBrowserRouter([
|
|
7
8
|
{
|
|
8
9
|
path: '/',
|
|
9
10
|
element: <AppShell />,
|
|
10
11
|
children: [
|
|
11
|
-
{
|
|
12
|
-
|
|
12
|
+
{
|
|
13
|
+
index: true,
|
|
14
|
+
element: <HomePage />,
|
|
15
|
+
handle: { pageTitleKey: 'pages.home.title' } satisfies AppRouteHandle,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
path: 'sample-data',
|
|
19
|
+
element: <SampleDataPage />,
|
|
20
|
+
handle: { pageTitleKey: 'sample_data.title' } satisfies AppRouteHandle,
|
|
21
|
+
},
|
|
13
22
|
],
|
|
14
23
|
},
|
|
15
24
|
])
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle, cn } from '@quadrokit/ui'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
export type ChartCardProps = {
|
|
5
|
+
title: string
|
|
6
|
+
description: string
|
|
7
|
+
/** Chart area (e.g. `h-[260px]`) */
|
|
8
|
+
chartClassName: string
|
|
9
|
+
children: ReactNode
|
|
10
|
+
className?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ChartCard({
|
|
14
|
+
title,
|
|
15
|
+
description,
|
|
16
|
+
chartClassName,
|
|
17
|
+
children,
|
|
18
|
+
className,
|
|
19
|
+
}: ChartCardProps) {
|
|
20
|
+
return (
|
|
21
|
+
<Card className={className}>
|
|
22
|
+
<CardHeader>
|
|
23
|
+
<CardTitle className="text-base">{title}</CardTitle>
|
|
24
|
+
<CardDescription>{description}</CardDescription>
|
|
25
|
+
</CardHeader>
|
|
26
|
+
<CardContent className={cn('pt-0', chartClassName)}>{children}</CardContent>
|
|
27
|
+
</Card>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { ChartCard } from './ChartCard'
|
|
4
|
+
import { MOCK_BARS, MOCK_KPI, MOCK_LINES, MOCK_REGIONS } from './dashboardMockData'
|
|
5
|
+
import { KpiCard } from './KpiCard'
|
|
6
|
+
import { RegionsBarChart } from './RegionsBarChart'
|
|
7
|
+
import { SeriesBarChart } from './SeriesBarChart'
|
|
8
|
+
import { TrendLineChart } from './TrendLineChart'
|
|
9
|
+
|
|
10
|
+
export function DashboardOverview() {
|
|
11
|
+
const { t } = useTranslation()
|
|
12
|
+
|
|
13
|
+
const kpiCards = useMemo(
|
|
14
|
+
() =>
|
|
15
|
+
MOCK_KPI.map((row) => ({
|
|
16
|
+
key: row.key,
|
|
17
|
+
title: t(`dashboard.kpi_${row.key}`),
|
|
18
|
+
value: row.value,
|
|
19
|
+
deltaLine: `${row.delta} · ${t('dashboard.trend_vs')}`,
|
|
20
|
+
trendPositive: row.positive,
|
|
21
|
+
})),
|
|
22
|
+
[t]
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="space-y-6">
|
|
27
|
+
<p className="text-sm text-muted-foreground">{t('dashboard.footnote')}</p>
|
|
28
|
+
|
|
29
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
30
|
+
{kpiCards.map((row) => (
|
|
31
|
+
<KpiCard
|
|
32
|
+
key={row.key}
|
|
33
|
+
title={row.title}
|
|
34
|
+
value={row.value}
|
|
35
|
+
deltaLine={row.deltaLine}
|
|
36
|
+
trendPositive={row.trendPositive}
|
|
37
|
+
/>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div className="grid gap-6 lg:grid-cols-2">
|
|
42
|
+
<ChartCard
|
|
43
|
+
className="min-h-[320px] shadow-sm"
|
|
44
|
+
title={t('dashboard.chart_bars_title')}
|
|
45
|
+
description={t('dashboard.chart_subtitle')}
|
|
46
|
+
chartClassName="h-[260px]"
|
|
47
|
+
>
|
|
48
|
+
<SeriesBarChart
|
|
49
|
+
data={MOCK_BARS}
|
|
50
|
+
seriesA={{ key: 'a', label: t('dashboard.series_a') }}
|
|
51
|
+
seriesB={{ key: 'b', label: t('dashboard.series_b') }}
|
|
52
|
+
/>
|
|
53
|
+
</ChartCard>
|
|
54
|
+
|
|
55
|
+
<ChartCard
|
|
56
|
+
className="min-h-[320px] shadow-sm"
|
|
57
|
+
title={t('dashboard.chart_lines_title')}
|
|
58
|
+
description={t('dashboard.chart_subtitle')}
|
|
59
|
+
chartClassName="h-[260px]"
|
|
60
|
+
>
|
|
61
|
+
<TrendLineChart
|
|
62
|
+
data={MOCK_LINES}
|
|
63
|
+
lineU={{ key: 'u', label: t('dashboard.series_u') }}
|
|
64
|
+
lineV={{ key: 'v', label: t('dashboard.series_v') }}
|
|
65
|
+
/>
|
|
66
|
+
</ChartCard>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<ChartCard
|
|
70
|
+
className="shadow-sm"
|
|
71
|
+
title={t('dashboard.chart_regions_title')}
|
|
72
|
+
description={t('dashboard.chart_subtitle')}
|
|
73
|
+
chartClassName="h-[220px]"
|
|
74
|
+
>
|
|
75
|
+
<RegionsBarChart data={MOCK_REGIONS} valueLabel={t('dashboard.count')} />
|
|
76
|
+
</ChartCard>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@quadrokit/ui'
|
|
2
|
+
|
|
3
|
+
export type KpiCardProps = {
|
|
4
|
+
title: string
|
|
5
|
+
value: string
|
|
6
|
+
/** Full second line, e.g. "+12% · vs. last period" */
|
|
7
|
+
deltaLine: string
|
|
8
|
+
trendPositive: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function KpiCard({ title, value, deltaLine, trendPositive }: KpiCardProps) {
|
|
12
|
+
return (
|
|
13
|
+
<Card className="shadow-sm">
|
|
14
|
+
<CardHeader className="pb-2">
|
|
15
|
+
<CardDescription>{title}</CardDescription>
|
|
16
|
+
<CardTitle className="text-2xl font-semibold tabular-nums">{value}</CardTitle>
|
|
17
|
+
</CardHeader>
|
|
18
|
+
<CardContent className="pt-0">
|
|
19
|
+
<p
|
|
20
|
+
className={
|
|
21
|
+
trendPositive
|
|
22
|
+
? 'text-xs font-medium text-emerald-600 dark:text-emerald-400'
|
|
23
|
+
: 'text-xs font-medium text-rose-600 dark:text-rose-400'
|
|
24
|
+
}
|
|
25
|
+
>
|
|
26
|
+
{deltaLine}
|
|
27
|
+
</p>
|
|
28
|
+
</CardContent>
|
|
29
|
+
</Card>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
|
2
|
+
import { chartAxisTick, chartGridStroke, chartTooltipContentStyle } from './chartTheme'
|
|
3
|
+
import type { RegionDatum } from './dashboardMockData'
|
|
4
|
+
|
|
5
|
+
export type RegionsBarChartProps = {
|
|
6
|
+
data: readonly RegionDatum[]
|
|
7
|
+
categoryKey?: keyof RegionDatum & string
|
|
8
|
+
valueKey?: keyof RegionDatum & string
|
|
9
|
+
valueLabel: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function RegionsBarChart({
|
|
13
|
+
data,
|
|
14
|
+
categoryKey = 'region',
|
|
15
|
+
valueKey = 'n',
|
|
16
|
+
valueLabel,
|
|
17
|
+
}: RegionsBarChartProps) {
|
|
18
|
+
return (
|
|
19
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
20
|
+
<BarChart
|
|
21
|
+
layout="vertical"
|
|
22
|
+
data={[...data]}
|
|
23
|
+
margin={{ top: 8, right: 24, left: 8, bottom: 8 }}
|
|
24
|
+
>
|
|
25
|
+
<CartesianGrid strokeDasharray="3 3" stroke={chartGridStroke} horizontal={false} />
|
|
26
|
+
<XAxis type="number" tick={chartAxisTick} axisLine={{ stroke: chartGridStroke }} />
|
|
27
|
+
<YAxis
|
|
28
|
+
type="category"
|
|
29
|
+
dataKey={categoryKey}
|
|
30
|
+
width={72}
|
|
31
|
+
tick={chartAxisTick}
|
|
32
|
+
axisLine={{ stroke: chartGridStroke }}
|
|
33
|
+
/>
|
|
34
|
+
<Tooltip contentStyle={chartTooltipContentStyle} />
|
|
35
|
+
<Bar
|
|
36
|
+
dataKey={valueKey}
|
|
37
|
+
name={valueLabel}
|
|
38
|
+
fill="hsl(var(--primary))"
|
|
39
|
+
radius={[0, 4, 4, 0]}
|
|
40
|
+
maxBarSize={18}
|
|
41
|
+
/>
|
|
42
|
+
</BarChart>
|
|
43
|
+
</ResponsiveContainer>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Bar,
|
|
3
|
+
BarChart,
|
|
4
|
+
CartesianGrid,
|
|
5
|
+
Legend,
|
|
6
|
+
ResponsiveContainer,
|
|
7
|
+
Tooltip,
|
|
8
|
+
XAxis,
|
|
9
|
+
YAxis,
|
|
10
|
+
} from 'recharts'
|
|
11
|
+
import {
|
|
12
|
+
chartAxisTick,
|
|
13
|
+
chartGridStroke,
|
|
14
|
+
chartLegendStyle,
|
|
15
|
+
chartTooltipContentStyle,
|
|
16
|
+
} from './chartTheme'
|
|
17
|
+
import type { BarDatum } from './dashboardMockData'
|
|
18
|
+
|
|
19
|
+
export type SeriesBarChartProps = {
|
|
20
|
+
data: readonly BarDatum[]
|
|
21
|
+
seriesA: { key: 'a'; label: string }
|
|
22
|
+
seriesB: { key: 'b'; label: string }
|
|
23
|
+
xKey?: keyof BarDatum & string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function SeriesBarChart({ data, seriesA, seriesB, xKey = 'name' }: SeriesBarChartProps) {
|
|
27
|
+
return (
|
|
28
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
29
|
+
<BarChart data={[...data]} margin={{ top: 8, right: 8, left: -8, bottom: 0 }}>
|
|
30
|
+
<CartesianGrid strokeDasharray="3 3" stroke={chartGridStroke} vertical={false} />
|
|
31
|
+
<XAxis dataKey={xKey} tick={chartAxisTick} axisLine={{ stroke: chartGridStroke }} />
|
|
32
|
+
<YAxis tick={chartAxisTick} axisLine={{ stroke: chartGridStroke }} width={32} />
|
|
33
|
+
<Tooltip
|
|
34
|
+
cursor={{ fill: 'hsl(var(--muted) / 0.35)' }}
|
|
35
|
+
contentStyle={chartTooltipContentStyle}
|
|
36
|
+
/>
|
|
37
|
+
<Legend wrapperStyle={chartLegendStyle} />
|
|
38
|
+
<Bar
|
|
39
|
+
dataKey={seriesA.key}
|
|
40
|
+
name={seriesA.label}
|
|
41
|
+
fill="hsl(var(--primary))"
|
|
42
|
+
radius={[4, 4, 0, 0]}
|
|
43
|
+
maxBarSize={40}
|
|
44
|
+
/>
|
|
45
|
+
<Bar
|
|
46
|
+
dataKey={seriesB.key}
|
|
47
|
+
name={seriesB.label}
|
|
48
|
+
fill="hsl(var(--muted-foreground) / 0.35)"
|
|
49
|
+
radius={[4, 4, 0, 0]}
|
|
50
|
+
maxBarSize={40}
|
|
51
|
+
/>
|
|
52
|
+
</BarChart>
|
|
53
|
+
</ResponsiveContainer>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CartesianGrid,
|
|
3
|
+
Legend,
|
|
4
|
+
Line,
|
|
5
|
+
LineChart,
|
|
6
|
+
ResponsiveContainer,
|
|
7
|
+
Tooltip,
|
|
8
|
+
XAxis,
|
|
9
|
+
YAxis,
|
|
10
|
+
} from 'recharts'
|
|
11
|
+
import {
|
|
12
|
+
chartAxisTick,
|
|
13
|
+
chartGridStroke,
|
|
14
|
+
chartLegendStyle,
|
|
15
|
+
chartTooltipContentStyle,
|
|
16
|
+
} from './chartTheme'
|
|
17
|
+
import type { LineDatum } from './dashboardMockData'
|
|
18
|
+
|
|
19
|
+
export type TrendLineChartProps = {
|
|
20
|
+
data: readonly LineDatum[]
|
|
21
|
+
xKey?: keyof LineDatum & string
|
|
22
|
+
lineU: { key: 'u'; label: string }
|
|
23
|
+
lineV: { key: 'v'; label: string }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function TrendLineChart({ data, xKey = 'x', lineU, lineV }: TrendLineChartProps) {
|
|
27
|
+
return (
|
|
28
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
29
|
+
<LineChart data={[...data]} margin={{ top: 8, right: 8, left: -8, bottom: 0 }}>
|
|
30
|
+
<CartesianGrid strokeDasharray="3 3" stroke={chartGridStroke} />
|
|
31
|
+
<XAxis dataKey={xKey} tick={chartAxisTick} axisLine={{ stroke: chartGridStroke }} />
|
|
32
|
+
<YAxis tick={chartAxisTick} axisLine={{ stroke: chartGridStroke }} width={32} />
|
|
33
|
+
<Tooltip contentStyle={chartTooltipContentStyle} />
|
|
34
|
+
<Legend wrapperStyle={chartLegendStyle} />
|
|
35
|
+
<Line
|
|
36
|
+
type="monotone"
|
|
37
|
+
dataKey={lineU.key}
|
|
38
|
+
name={lineU.label}
|
|
39
|
+
stroke="hsl(var(--primary))"
|
|
40
|
+
strokeWidth={2}
|
|
41
|
+
dot={{ r: 3 }}
|
|
42
|
+
activeDot={{ r: 5 }}
|
|
43
|
+
/>
|
|
44
|
+
<Line
|
|
45
|
+
type="monotone"
|
|
46
|
+
dataKey={lineV.key}
|
|
47
|
+
name={lineV.label}
|
|
48
|
+
stroke="hsl(var(--muted-foreground))"
|
|
49
|
+
strokeWidth={2}
|
|
50
|
+
dot={{ r: 3 }}
|
|
51
|
+
/>
|
|
52
|
+
</LineChart>
|
|
53
|
+
</ResponsiveContainer>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Shared Recharts styling aligned with QuadroKit CSS variables (light/dark). */
|
|
4
|
+
export const chartAxisTick = {
|
|
5
|
+
fill: 'hsl(var(--muted-foreground))',
|
|
6
|
+
fontSize: 11,
|
|
7
|
+
} as const
|
|
8
|
+
|
|
9
|
+
export const chartGridStroke = 'hsl(var(--border))'
|
|
10
|
+
|
|
11
|
+
export const chartTooltipContentStyle: CSSProperties = {
|
|
12
|
+
background: 'hsl(var(--card))',
|
|
13
|
+
border: '1px solid hsl(var(--border))',
|
|
14
|
+
borderRadius: '8px',
|
|
15
|
+
fontSize: '12px',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const chartLegendStyle = { fontSize: '12px' } as const
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Static demo data — replace with props from your API / `quadro` client. */
|
|
2
|
+
|
|
3
|
+
export type KpiDatum = {
|
|
4
|
+
key: string
|
|
5
|
+
value: string
|
|
6
|
+
delta: string
|
|
7
|
+
positive: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const MOCK_KPI: readonly KpiDatum[] = [
|
|
11
|
+
{ key: 'revenue', value: '$587.54', delta: '+12%', positive: true },
|
|
12
|
+
{ key: 'sales', value: '4,500', delta: '+8%', positive: true },
|
|
13
|
+
{ key: 'customers', value: '2,242', delta: '-3%', positive: false },
|
|
14
|
+
{ key: 'orders', value: '1,128', delta: '+15%', positive: true },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export type BarDatum = {
|
|
18
|
+
name: string
|
|
19
|
+
a: number
|
|
20
|
+
b: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const MOCK_BARS: readonly BarDatum[] = [
|
|
24
|
+
{ name: 'Jan', a: 40, b: 24 },
|
|
25
|
+
{ name: 'Feb', a: 30, b: 38 },
|
|
26
|
+
{ name: 'Mar', a: 55, b: 42 },
|
|
27
|
+
{ name: 'Apr', a: 45, b: 30 },
|
|
28
|
+
{ name: 'May', a: 60, b: 48 },
|
|
29
|
+
{ name: 'Jun', a: 52, b: 55 },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
export type LineDatum = { x: number; u: number; v: number }
|
|
33
|
+
|
|
34
|
+
export const MOCK_LINES: readonly LineDatum[] = [
|
|
35
|
+
{ x: 1, u: 12, v: 8 },
|
|
36
|
+
{ x: 2, u: 18, v: 14 },
|
|
37
|
+
{ x: 3, u: 15, v: 20 },
|
|
38
|
+
{ x: 4, u: 22, v: 16 },
|
|
39
|
+
{ x: 5, u: 28, v: 24 },
|
|
40
|
+
{ x: 6, u: 25, v: 30 },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
export type RegionDatum = { region: string; n: number }
|
|
44
|
+
|
|
45
|
+
export const MOCK_REGIONS: readonly RegionDatum[] = [
|
|
46
|
+
{ region: 'North', n: 14 },
|
|
47
|
+
{ region: 'South', n: 19 },
|
|
48
|
+
{ region: 'East', n: 12 },
|
|
49
|
+
{ region: 'West', n: 17 },
|
|
50
|
+
{ region: 'Other', n: 9 },
|
|
51
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DashboardOverview } from './DashboardOverview'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { cn } from '@quadrokit/ui'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { Outlet } from 'react-router-dom'
|
|
5
|
+
import { PageHeading } from './PageHeading'
|
|
6
|
+
import { Sidebar } from './Sidebar'
|
|
7
|
+
import { TopHeader } from './TopHeader'
|
|
8
|
+
|
|
9
|
+
export function AppShell() {
|
|
10
|
+
const { t } = useTranslation()
|
|
11
|
+
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex min-h-dvh bg-background text-foreground">
|
|
15
|
+
<div className="hidden w-56 shrink-0 border-r border-border md:block">
|
|
16
|
+
<Sidebar className="sticky top-0 h-dvh" />
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div
|
|
20
|
+
className={cn(
|
|
21
|
+
'fixed inset-0 z-50 md:hidden',
|
|
22
|
+
mobileNavOpen ? 'pointer-events-auto' : 'pointer-events-none'
|
|
23
|
+
)}
|
|
24
|
+
aria-hidden={!mobileNavOpen}
|
|
25
|
+
>
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
className={cn(
|
|
29
|
+
'absolute inset-0 bg-background/80 backdrop-blur-sm transition-opacity',
|
|
30
|
+
mobileNavOpen ? 'opacity-100' : 'opacity-0'
|
|
31
|
+
)}
|
|
32
|
+
aria-label={t('layout.close_menu')}
|
|
33
|
+
tabIndex={mobileNavOpen ? 0 : -1}
|
|
34
|
+
onClick={() => setMobileNavOpen(false)}
|
|
35
|
+
/>
|
|
36
|
+
<div
|
|
37
|
+
className={cn(
|
|
38
|
+
'absolute left-0 top-0 h-full w-56 max-w-[85vw] border-r border-border bg-card shadow-lg transition-transform',
|
|
39
|
+
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<Sidebar onNavigate={() => setMobileNavOpen(false)} />
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
47
|
+
<TopHeader onMenuClick={() => setMobileNavOpen(true)} />
|
|
48
|
+
<main className="flex-1 overflow-auto px-4 py-6 md:px-6 lg:px-8">
|
|
49
|
+
<PageHeading />
|
|
50
|
+
<Outlet />
|
|
51
|
+
</main>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Button } from '@quadrokit/ui'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
|
|
4
|
+
const LOCALES = [
|
|
5
|
+
{ code: 'en', labelKey: 'layout.lang_en' as const },
|
|
6
|
+
{ code: 'fr', labelKey: 'layout.lang_fr' as const },
|
|
7
|
+
] as const
|
|
8
|
+
|
|
9
|
+
export function LanguageSwitcher() {
|
|
10
|
+
const { i18n, t } = useTranslation()
|
|
11
|
+
const lng = i18n.resolvedLanguage ?? i18n.language
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<fieldset className="m-0 flex min-w-0 items-center gap-1 rounded-md border border-border bg-card p-0.5">
|
|
15
|
+
<legend className="sr-only">{t('layout.language')}</legend>
|
|
16
|
+
{LOCALES.map(({ code, labelKey }) => (
|
|
17
|
+
<Button
|
|
18
|
+
key={code}
|
|
19
|
+
type="button"
|
|
20
|
+
size="sm"
|
|
21
|
+
variant={lng.startsWith(code) ? 'default' : 'ghost'}
|
|
22
|
+
className="h-8 px-2 text-xs"
|
|
23
|
+
onClick={() => {
|
|
24
|
+
void i18n.changeLanguage(code)
|
|
25
|
+
try {
|
|
26
|
+
localStorage.setItem('quadrokit:lng', code)
|
|
27
|
+
} catch {
|
|
28
|
+
/* ignore */
|
|
29
|
+
}
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
{t(labelKey)}
|
|
33
|
+
</Button>
|
|
34
|
+
))}
|
|
35
|
+
</fieldset>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Button, useDismissOnInteractOutside } from '@quadrokit/ui'
|
|
2
|
+
import { useRef, useState } from 'react'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
|
|
5
|
+
/** Static notification list — replace with real data / subscriptions later. */
|
|
6
|
+
const PLACEHOLDER_ITEMS = [
|
|
7
|
+
{ id: '1', titleKey: 'layout.notif_1' as const, timeKey: 'layout.notif_time_1' as const },
|
|
8
|
+
{ id: '2', titleKey: 'layout.notif_2' as const, timeKey: 'layout.notif_time_2' as const },
|
|
9
|
+
] as const
|
|
10
|
+
|
|
11
|
+
export function NotificationsMenu() {
|
|
12
|
+
const { t } = useTranslation()
|
|
13
|
+
const [open, setOpen] = useState(false)
|
|
14
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
15
|
+
|
|
16
|
+
useDismissOnInteractOutside({
|
|
17
|
+
enabled: open,
|
|
18
|
+
containerRef: rootRef,
|
|
19
|
+
onDismiss: () => setOpen(false),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div ref={rootRef} className="relative">
|
|
24
|
+
<Button
|
|
25
|
+
type="button"
|
|
26
|
+
variant="outline"
|
|
27
|
+
size="icon"
|
|
28
|
+
className="relative h-9 w-9 shrink-0"
|
|
29
|
+
aria-expanded={open}
|
|
30
|
+
aria-haspopup="dialog"
|
|
31
|
+
aria-label={t('layout.notifications')}
|
|
32
|
+
onClick={() => setOpen((o) => !o)}
|
|
33
|
+
>
|
|
34
|
+
<BellIcon className="h-4 w-4" />
|
|
35
|
+
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-destructive" aria-hidden />
|
|
36
|
+
</Button>
|
|
37
|
+
{open ? (
|
|
38
|
+
<div
|
|
39
|
+
className="absolute right-0 z-50 mt-1 w-80 max-w-[calc(100vw-2rem)] rounded-lg border border-border bg-popover p-2 text-sm text-popover-foreground shadow-lg"
|
|
40
|
+
role="dialog"
|
|
41
|
+
aria-label={t('layout.notifications')}
|
|
42
|
+
>
|
|
43
|
+
<p className="border-b border-border px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
44
|
+
{t('layout.notifications')}
|
|
45
|
+
</p>
|
|
46
|
+
<ul className="max-h-64 overflow-auto py-1">
|
|
47
|
+
{PLACEHOLDER_ITEMS.map((item) => (
|
|
48
|
+
<li key={item.id} className="rounded-md px-2 py-2 hover:bg-muted/60">
|
|
49
|
+
<p className="font-medium leading-snug">{t(item.titleKey)}</p>
|
|
50
|
+
<p className="text-xs text-muted-foreground">{t(item.timeKey)}</p>
|
|
51
|
+
</li>
|
|
52
|
+
))}
|
|
53
|
+
</ul>
|
|
54
|
+
</div>
|
|
55
|
+
) : null}
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function BellIcon({ className }: { className?: string }) {
|
|
61
|
+
return (
|
|
62
|
+
<svg
|
|
63
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
64
|
+
viewBox="0 0 24 24"
|
|
65
|
+
fill="none"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
strokeWidth="2"
|
|
68
|
+
strokeLinecap="round"
|
|
69
|
+
strokeLinejoin="round"
|
|
70
|
+
className={className}
|
|
71
|
+
aria-hidden="true"
|
|
72
|
+
focusable="false"
|
|
73
|
+
>
|
|
74
|
+
<title>Notifications</title>
|
|
75
|
+
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
|
76
|
+
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
|
77
|
+
</svg>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
import { Link, useLocation, useMatches } from 'react-router-dom'
|
|
3
|
+
import type { AppRouteHandle } from '@/types/router'
|
|
4
|
+
|
|
5
|
+
export function PageHeading() {
|
|
6
|
+
const { t } = useTranslation()
|
|
7
|
+
const { pathname } = useLocation()
|
|
8
|
+
const matches = useMatches()
|
|
9
|
+
const leaf = matches[matches.length - 1]
|
|
10
|
+
const handle = leaf?.handle as AppRouteHandle | undefined
|
|
11
|
+
|
|
12
|
+
const segmentKey =
|
|
13
|
+
pathname === '/'
|
|
14
|
+
? ('breadcrumb.segment_home' as const)
|
|
15
|
+
: pathname === '/sample-data'
|
|
16
|
+
? ('breadcrumb.segment_sample_data' as const)
|
|
17
|
+
: ('breadcrumb.segment_unknown' as const)
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<header className="mb-6">
|
|
21
|
+
<nav
|
|
22
|
+
aria-label={t('layout.breadcrumb_label')}
|
|
23
|
+
className="mb-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground"
|
|
24
|
+
>
|
|
25
|
+
<Link to="/" className="hover:text-foreground">
|
|
26
|
+
{t('breadcrumb.app')}
|
|
27
|
+
</Link>
|
|
28
|
+
<span aria-hidden className="text-border">
|
|
29
|
+
/
|
|
30
|
+
</span>
|
|
31
|
+
<span className="font-medium text-foreground">{t(segmentKey)}</span>
|
|
32
|
+
</nav>
|
|
33
|
+
{handle?.pageTitleKey ? (
|
|
34
|
+
<h1 className="text-2xl font-semibold tracking-tight md:text-3xl">
|
|
35
|
+
{t(handle.pageTitleKey)}
|
|
36
|
+
</h1>
|
|
37
|
+
) : null}
|
|
38
|
+
</header>
|
|
39
|
+
)
|
|
40
|
+
}
|