@squadbase/vantage-template 0.0.1

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.
Files changed (99) hide show
  1. package/README.md +112 -0
  2. package/base-template/.squadbase/skills/vantage-add-feature/SKILL.md +162 -0
  3. package/base-template/.squadbase/skills/vantage-app/SKILL.md +120 -0
  4. package/base-template/.squadbase/skills/vantage-pitfalls/SKILL.md +137 -0
  5. package/base-template/AGENTS.md +339 -0
  6. package/base-template/_404.tsx +18 -0
  7. package/base-template/_error.tsx +14 -0
  8. package/base-template/_gitignore +5 -0
  9. package/base-template/_layout.tsx +60 -0
  10. package/base-template/components/user-card.tsx +132 -0
  11. package/base-template/index.tsx +59 -0
  12. package/base-template/package.json +27 -0
  13. package/base-template/server/api/health.ts +12 -0
  14. package/base-template/squadbase.yml +5 -0
  15. package/base-template/styles.css +18 -0
  16. package/base-template/tsconfig.json +28 -0
  17. package/chart-presets/berry.css +16 -0
  18. package/chart-presets/blue.css +16 -0
  19. package/chart-presets/forest.css +16 -0
  20. package/chart-presets/mono.css +16 -0
  21. package/chart-presets/ocean.css +16 -0
  22. package/chart-presets/sunset.css +16 -0
  23. package/dist/index.js +1157 -0
  24. package/package.json +55 -0
  25. package/ui-templates/CLAUDE.md +200 -0
  26. package/ui-templates/blank/manifest.json +8 -0
  27. package/ui-templates/blank/pages/index.tsx +33 -0
  28. package/ui-templates/blank-ja/manifest.json +8 -0
  29. package/ui-templates/blank-ja/pages/index.tsx +33 -0
  30. package/ui-templates/chart-grid/lib/mock-data.ts +139 -0
  31. package/ui-templates/chart-grid/lib/types.ts +31 -0
  32. package/ui-templates/chart-grid/manifest.json +10 -0
  33. package/ui-templates/chart-grid/pages/index.tsx +331 -0
  34. package/ui-templates/chart-grid/preview-square.png +0 -0
  35. package/ui-templates/chart-grid/preview-wide.png +0 -0
  36. package/ui-templates/chart-grid-ja/lib/mock-data.ts +139 -0
  37. package/ui-templates/chart-grid-ja/lib/types.ts +31 -0
  38. package/ui-templates/chart-grid-ja/manifest.json +10 -0
  39. package/ui-templates/chart-grid-ja/pages/index.tsx +331 -0
  40. package/ui-templates/chart-grid-ja/preview-square.png +0 -0
  41. package/ui-templates/chart-grid-ja/preview-wide.png +0 -0
  42. package/ui-templates/funnel/components/chart-helpers.ts +11 -0
  43. package/ui-templates/funnel/components/stage-table.tsx +63 -0
  44. package/ui-templates/funnel/lib/mock-data.ts +22 -0
  45. package/ui-templates/funnel/lib/types.ts +11 -0
  46. package/ui-templates/funnel/manifest.json +12 -0
  47. package/ui-templates/funnel/pages/index.tsx +252 -0
  48. package/ui-templates/funnel/preview-square.png +0 -0
  49. package/ui-templates/funnel/preview-wide.png +0 -0
  50. package/ui-templates/funnel-ja/components/chart-helpers.ts +11 -0
  51. package/ui-templates/funnel-ja/components/stage-table.tsx +63 -0
  52. package/ui-templates/funnel-ja/lib/mock-data.ts +22 -0
  53. package/ui-templates/funnel-ja/lib/types.ts +11 -0
  54. package/ui-templates/funnel-ja/manifest.json +12 -0
  55. package/ui-templates/funnel-ja/pages/index.tsx +251 -0
  56. package/ui-templates/funnel-ja/preview-square.png +0 -0
  57. package/ui-templates/funnel-ja/preview-wide.png +0 -0
  58. package/ui-templates/kpi-chart-advanced/components/campaign-table.tsx +69 -0
  59. package/ui-templates/kpi-chart-advanced/lib/mock-data.ts +109 -0
  60. package/ui-templates/kpi-chart-advanced/lib/types.ts +38 -0
  61. package/ui-templates/kpi-chart-advanced/manifest.json +11 -0
  62. package/ui-templates/kpi-chart-advanced/pages/index.tsx +339 -0
  63. package/ui-templates/kpi-chart-advanced/preview-square.png +0 -0
  64. package/ui-templates/kpi-chart-advanced/preview-wide.png +0 -0
  65. package/ui-templates/kpi-chart-advanced-ja/components/campaign-table.tsx +69 -0
  66. package/ui-templates/kpi-chart-advanced-ja/lib/mock-data.ts +109 -0
  67. package/ui-templates/kpi-chart-advanced-ja/lib/types.ts +38 -0
  68. package/ui-templates/kpi-chart-advanced-ja/manifest.json +11 -0
  69. package/ui-templates/kpi-chart-advanced-ja/pages/index.tsx +333 -0
  70. package/ui-templates/kpi-chart-advanced-ja/preview-square.png +0 -0
  71. package/ui-templates/kpi-chart-advanced-ja/preview-wide.png +0 -0
  72. package/ui-templates/kpi-chart-simple/components/top-items-table.tsx +73 -0
  73. package/ui-templates/kpi-chart-simple/lib/types.ts +44 -0
  74. package/ui-templates/kpi-chart-simple/manifest.json +11 -0
  75. package/ui-templates/kpi-chart-simple/pages/index.tsx +200 -0
  76. package/ui-templates/kpi-chart-simple/preview-square.png +0 -0
  77. package/ui-templates/kpi-chart-simple/preview-wide.png +0 -0
  78. package/ui-templates/kpi-chart-simple/server/kpi-summary.ts +141 -0
  79. package/ui-templates/kpi-chart-simple-ja/components/top-items-table.tsx +73 -0
  80. package/ui-templates/kpi-chart-simple-ja/lib/types.ts +44 -0
  81. package/ui-templates/kpi-chart-simple-ja/manifest.json +11 -0
  82. package/ui-templates/kpi-chart-simple-ja/pages/index.tsx +200 -0
  83. package/ui-templates/kpi-chart-simple-ja/preview-square.png +0 -0
  84. package/ui-templates/kpi-chart-simple-ja/preview-wide.png +0 -0
  85. package/ui-templates/kpi-chart-simple-ja/server/kpi-summary.ts +141 -0
  86. package/ui-templates/table-focus/components/detail-table.tsx +88 -0
  87. package/ui-templates/table-focus/lib/mock-data.ts +69 -0
  88. package/ui-templates/table-focus/lib/types.ts +24 -0
  89. package/ui-templates/table-focus/manifest.json +11 -0
  90. package/ui-templates/table-focus/pages/index.tsx +180 -0
  91. package/ui-templates/table-focus/preview-square.png +0 -0
  92. package/ui-templates/table-focus/preview-wide.png +0 -0
  93. package/ui-templates/table-focus-ja/components/detail-table.tsx +95 -0
  94. package/ui-templates/table-focus-ja/lib/mock-data.ts +69 -0
  95. package/ui-templates/table-focus-ja/lib/types.ts +24 -0
  96. package/ui-templates/table-focus-ja/manifest.json +11 -0
  97. package/ui-templates/table-focus-ja/pages/index.tsx +180 -0
  98. package/ui-templates/table-focus-ja/preview-square.png +0 -0
  99. package/ui-templates/table-focus-ja/preview-wide.png +0 -0
@@ -0,0 +1,200 @@
1
+ import { Activity, DollarSign, ShoppingCart, Users } from "lucide-react"
2
+ import { definePage } from "@squadbase/vantage"
3
+ import {
4
+ DashboardCard,
5
+ DashboardCardAction,
6
+ DashboardCardContent,
7
+ DashboardCardHeader,
8
+ DashboardCardPreset,
9
+ DashboardCardTitle,
10
+ EChart,
11
+ PageShell,
12
+ PageShellContent,
13
+ PageShellDescription,
14
+ PageShellHeader,
15
+ PageShellHeaderEnd,
16
+ PageShellHeading,
17
+ PageShellTitle,
18
+ Placeholder,
19
+ SegmentedControl,
20
+ Sparkline,
21
+ } from "@squadbase/vantage/components"
22
+ import type { EChartsOption, SegmentOption } from "@squadbase/vantage/components"
23
+ import { Button, ErrorState, Loading } from "@squadbase/vantage/ui"
24
+ import { useApiQuery } from "@squadbase/vantage/query"
25
+ import { useSearchParam } from "@squadbase/vantage/router"
26
+
27
+ import { TopItemsTable } from "./components/kpi-chart-simple/top-items-table"
28
+ import type { KpiId, KpiSummary, TrendPoint } from "./lib/kpi-chart-simple/types"
29
+
30
+ export const page = definePage({
31
+ title: "KPI + Chart (Simple)",
32
+ description:
33
+ "4 KPI cards, a single trend line, and a ranked-items table, served by server/api and filtered through the URL.",
34
+ })
35
+
36
+ // ── formatting ───────────────────────────────────────────────────────────────
37
+
38
+ function formatNumber(n: number): string {
39
+ const sign = n < 0 ? "-" : ""
40
+ const abs = Math.abs(n)
41
+ if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`
42
+ if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(1)}K`
43
+ return n.toLocaleString("en-US")
44
+ }
45
+
46
+ function formatCurrency(n: number): string {
47
+ const sign = n < 0 ? "-" : ""
48
+ const abs = Math.abs(n)
49
+ if (abs >= 1_000_000) return `${sign}$${(abs / 1_000_000).toFixed(2)}M`
50
+ if (abs >= 1_000) return `${sign}$${(abs / 1_000).toFixed(1)}K`
51
+ return `${sign}$${abs.toFixed(0)}`
52
+ }
53
+
54
+ // ── chart options ────────────────────────────────────────────────────────────
55
+
56
+ function trendOption(data: TrendPoint[]): EChartsOption {
57
+ return {
58
+ tooltip: { trigger: "axis" },
59
+ legend: { bottom: 0 },
60
+ grid: { left: "3%", right: "4%", bottom: "10%", containLabel: true },
61
+ xAxis: {
62
+ type: "category",
63
+ data: data.map((d) => d.date),
64
+ boundaryGap: false,
65
+ axisLabel: { formatter: (value: string) => value.slice(5) },
66
+ },
67
+ yAxis: {
68
+ type: "value",
69
+ axisLabel: { formatter: (v: number) => formatNumber(v) },
70
+ },
71
+ series: [
72
+ {
73
+ name: "Series A",
74
+ type: "line",
75
+ smooth: true,
76
+ showSymbol: false,
77
+ data: data.map((d) => d.revenue),
78
+ areaStyle: { opacity: 0.18 },
79
+ },
80
+ ],
81
+ }
82
+ }
83
+
84
+ // ── page ─────────────────────────────────────────────────────────────────────
85
+
86
+ /** Display labels live here, not in the payload the API returns. */
87
+ const KPI_META: Record<
88
+ KpiId,
89
+ { label: string; icon: typeof DollarSign; format: (value: number) => string }
90
+ > = {
91
+ "total-revenue": { label: "Metric 1", icon: DollarSign, format: formatCurrency },
92
+ "active-users": {
93
+ label: "Metric 2",
94
+ icon: Users,
95
+ format: (v) => Math.round(v).toLocaleString("en-US"),
96
+ },
97
+ "conversion-rate": { label: "Metric 3", icon: Activity, format: (v) => `${v.toFixed(2)}%` },
98
+ aov: { label: "Metric 4", icon: ShoppingCart, format: (v) => `$${v.toFixed(2)}` },
99
+ }
100
+
101
+ const RANGE_OPTIONS: SegmentOption[] = [
102
+ { label: "7 days", value: "7d" },
103
+ { label: "30 days", value: "30d" },
104
+ { label: "90 days", value: "90d" },
105
+ ]
106
+
107
+ function DashboardBody({ data }: { data: KpiSummary }) {
108
+ return (
109
+ <>
110
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
111
+ {data.kpis.map((kpi) => {
112
+ const { label, icon: Icon, format } = KPI_META[kpi.id]
113
+ return (
114
+ <DashboardCard key={kpi.id}>
115
+ <DashboardCardHeader>
116
+ <DashboardCardTitle className="text-muted-foreground">
117
+ {label}
118
+ </DashboardCardTitle>
119
+ <DashboardCardAction>
120
+ <Icon className="size-4 text-muted-foreground" />
121
+ </DashboardCardAction>
122
+ </DashboardCardHeader>
123
+ <DashboardCardContent>
124
+ <Placeholder className="text-2xl font-bold">
125
+ {format(kpi.value)}
126
+ </Placeholder>
127
+ <div className="mt-2">
128
+ <Placeholder className="text-sm font-medium">
129
+ {kpi.change >= 0 ? `+${kpi.change}%` : `${kpi.change}%`}
130
+ </Placeholder>
131
+ </div>
132
+ <Sparkline
133
+ data={kpi.sparklineData.map((v) => ({ value: v }))}
134
+ height={32}
135
+ area
136
+ className="mt-3"
137
+ />
138
+ </DashboardCardContent>
139
+ </DashboardCard>
140
+ )
141
+ })}
142
+ </div>
143
+
144
+ <DashboardCardPreset title="Trend" description="Values over the selected period">
145
+ <EChart option={trendOption(data.trend)} height="320px" />
146
+ </DashboardCardPreset>
147
+
148
+ <DashboardCardPreset title="Top items" description="Ranked by value">
149
+ <TopItemsTable data={data.topItems} />
150
+ </DashboardCardPreset>
151
+ </>
152
+ )
153
+ }
154
+
155
+ export default function HomePage() {
156
+ // Filter state belongs in the URL rather than `useState`: it survives a
157
+ // reload, and a pasted link reproduces exactly what you were looking at.
158
+ const [range, setRange] = useSearchParam("range", "30d")
159
+
160
+ // `search` goes into the query string *and* the cache key, so changing the
161
+ // range refetches instead of serving the previous window.
162
+ const summary = useApiQuery<KpiSummary>("/api/kpi-summary", { search: { range } })
163
+
164
+ return (
165
+ <PageShell>
166
+ <PageShellHeader>
167
+ <PageShellHeading>
168
+ <PageShellTitle>[Template] Performance overview</PageShellTitle>
169
+ <PageShellDescription>
170
+ Key metrics and trends for the selected period.
171
+ </PageShellDescription>
172
+ </PageShellHeading>
173
+ <PageShellHeaderEnd>
174
+ <SegmentedControl
175
+ options={RANGE_OPTIONS}
176
+ value={range}
177
+ onChange={setRange}
178
+ ariaLabel="Period"
179
+ />
180
+ </PageShellHeaderEnd>
181
+ </PageShellHeader>
182
+
183
+ <PageShellContent className="space-y-6">
184
+ {summary.isPending ? (
185
+ <Loading />
186
+ ) : summary.isError ? (
187
+ // `summary.error` is an `ApiError`: `message` is the text the handler
188
+ // passed to `HttpError`, so there is no cast and no generic wording.
189
+ <ErrorState
190
+ title="Could not load the dashboard"
191
+ message={summary.error.message}
192
+ action={<Button onClick={() => void summary.refetch()}>Retry</Button>}
193
+ />
194
+ ) : (
195
+ <DashboardBody data={summary.data} />
196
+ )}
197
+ </PageShellContent>
198
+ </PageShell>
199
+ )
200
+ }
@@ -0,0 +1,141 @@
1
+ import type { ApiContext } from "@squadbase/vantage/server"
2
+ import { HttpError } from "@squadbase/vantage/server"
3
+
4
+ import type {
5
+ KpiItem,
6
+ KpiSummary,
7
+ RangeKey,
8
+ TopItemRow,
9
+ TrendPoint,
10
+ } from "../../lib/kpi-chart-simple/types"
11
+
12
+ /**
13
+ * `GET /api/kpi-summary?range=7d|30d|90d`
14
+ *
15
+ * The one file to rewrite when you connect real data. Everything below the
16
+ * "sample data" banner is a stand-in for a query — swap it for your warehouse
17
+ * client (credentials come from `ctx.env`, never from the client bundle) and
18
+ * the page keeps working as long as the `KpiSummary` shape holds.
19
+ */
20
+
21
+ const RANGE_DAYS: Record<RangeKey, number> = { "7d": 7, "30d": 30, "90d": 90 }
22
+
23
+ function isRangeKey(value: string): value is RangeKey {
24
+ return Object.hasOwn(RANGE_DAYS, value)
25
+ }
26
+
27
+ // ── sample data ──────────────────────────────────────────────────────────────
28
+ // Deterministic so the dashboard looks the same on every request.
29
+
30
+ const HISTORY_DAYS = 180
31
+ const DAY_MS = 86_400_000
32
+
33
+ const HISTORY: TrendPoint[] = Array.from({ length: HISTORY_DAYS }, (_, i) => {
34
+ const weekly = Math.sin((i / 7) * Math.PI * 2) * 4_200
35
+ const monthly = Math.sin((i / 30) * Math.PI * 2) * 2_600
36
+ const revenue = Math.round(34_000 + i * 95 + weekly + monthly)
37
+ const orders = Math.round(revenue / (74 + Math.sin(i / 11) * 6))
38
+ const visitors = Math.round(orders * (28 + Math.sin(i / 9) * 4))
39
+ return {
40
+ date: new Date(Date.UTC(2024, 0, 1) + i * DAY_MS).toISOString().slice(0, 10),
41
+ revenue,
42
+ orders,
43
+ visitors,
44
+ }
45
+ })
46
+
47
+ const TOP_ITEMS: TopItemRow[] = [
48
+ { id: "p-01", name: "Item 1", category: "Category A", revenue: 184_320, units: 1_232, share: 0.142 },
49
+ { id: "p-02", name: "Item 2", category: "Category B", revenue: 162_480, units: 812, share: 0.125 },
50
+ { id: "p-03", name: "Item 3", category: "Category C", revenue: 138_750, units: 463, share: 0.107 },
51
+ { id: "p-04", name: "Item 4", category: "Category D", revenue: 121_900, units: 974, share: 0.094 },
52
+ { id: "p-05", name: "Item 5", category: "Category D", revenue: 98_640, units: 1_644, share: 0.076 },
53
+ { id: "p-06", name: "Item 6", category: "Category B", revenue: 87_220, units: 821, share: 0.067 },
54
+ { id: "p-07", name: "Item 7", category: "Category A", revenue: 81_540, units: 679, share: 0.063 },
55
+ { id: "p-08", name: "Item 8", category: "Category E", revenue: 72_410, units: 905, share: 0.056 },
56
+ { id: "p-09", name: "Item 9", category: "Category E", revenue: 68_900, units: 138, share: 0.053 },
57
+ { id: "p-10", name: "Item 10", category: "Category D", revenue: 54_220, units: 1_356, share: 0.042 },
58
+ ]
59
+
60
+ // ── aggregation ──────────────────────────────────────────────────────────────
61
+
62
+ function total(rows: TrendPoint[], field: "revenue" | "orders" | "visitors"): number {
63
+ return rows.reduce((acc, row) => acc + row[field], 0)
64
+ }
65
+
66
+ /** Percent change vs. the preceding window, rounded to one decimal. */
67
+ function delta(current: number, prior: number): number {
68
+ if (prior === 0) return 0
69
+ return Math.round(((current - prior) / prior) * 1_000) / 10
70
+ }
71
+
72
+ function sparkline(rows: TrendPoint[], project: (row: TrendPoint) => number): number[] {
73
+ const step = Math.max(1, Math.floor(rows.length / 12))
74
+ return rows.filter((_, i) => i % step === 0).map(project)
75
+ }
76
+
77
+ function buildKpis(current: TrendPoint[], prior: TrendPoint[]): KpiItem[] {
78
+ const revenue = total(current, "revenue")
79
+ const orders = total(current, "orders")
80
+ const visitors = total(current, "visitors")
81
+ const priorRevenue = total(prior, "revenue")
82
+ const priorOrders = total(prior, "orders")
83
+ const priorVisitors = total(prior, "visitors")
84
+
85
+ const conversion = visitors === 0 ? 0 : (orders / visitors) * 100
86
+ const priorConversion = priorVisitors === 0 ? 0 : (priorOrders / priorVisitors) * 100
87
+ const aov = orders === 0 ? 0 : revenue / orders
88
+ const priorAov = priorOrders === 0 ? 0 : priorRevenue / priorOrders
89
+
90
+ return [
91
+ {
92
+ id: "total-revenue",
93
+ value: revenue,
94
+ change: delta(revenue, priorRevenue),
95
+ sparklineData: sparkline(current, (row) => row.revenue),
96
+ },
97
+ {
98
+ id: "active-users",
99
+ value: visitors,
100
+ change: delta(visitors, priorVisitors),
101
+ sparklineData: sparkline(current, (row) => row.visitors),
102
+ },
103
+ {
104
+ id: "conversion-rate",
105
+ value: Math.round(conversion * 100) / 100,
106
+ change: delta(conversion, priorConversion),
107
+ sparklineData: sparkline(current, (row) => (row.orders / row.visitors) * 100),
108
+ },
109
+ {
110
+ id: "aov",
111
+ value: Math.round(aov * 100) / 100,
112
+ change: delta(aov, priorAov),
113
+ sparklineData: sparkline(current, (row) => row.revenue / row.orders),
114
+ },
115
+ ]
116
+ }
117
+
118
+ // ── handler ──────────────────────────────────────────────────────────────────
119
+
120
+ export async function GET({ request }: ApiContext) {
121
+ const range = new URL(request.url).searchParams.get("range") ?? "30d"
122
+
123
+ // Errors the client should see must be `HttpError`; anything else is logged
124
+ // and flattened to a generic 500.
125
+ if (!isRangeKey(range)) {
126
+ throw new HttpError(400, `Unknown range "${range}". Expected one of 7d, 30d, 90d.`)
127
+ }
128
+
129
+ const days = RANGE_DAYS[range]
130
+ const current = HISTORY.slice(-days)
131
+ const prior = HISTORY.slice(-days * 2, -days)
132
+
133
+ const payload: KpiSummary = {
134
+ range,
135
+ kpis: buildKpis(current, prior),
136
+ trend: current,
137
+ topItems: TOP_ITEMS,
138
+ }
139
+
140
+ return Response.json(payload)
141
+ }
@@ -0,0 +1,73 @@
1
+ import { useMemo } from "react"
2
+ import { DataTablePreset, Placeholder } from "@squadbase/vantage/components"
3
+ import type { ColumnDef } from "@squadbase/vantage/components"
4
+
5
+ import type { TopItemRow } from "../../lib/kpi-chart-simple/types"
6
+
7
+ function formatCurrency(n: number, opts: { short?: boolean } = {}): string {
8
+ const sign = n < 0 ? "-" : ""
9
+ const abs = Math.abs(n)
10
+ if (opts.short) {
11
+ if (abs >= 100_000_000) return `${sign}${(abs / 100_000_000).toFixed(2)}億円`
12
+ if (abs >= 10_000) return `${sign}${(abs / 10_000).toFixed(0)}万円`
13
+ return `${sign}¥${abs.toLocaleString("ja-JP")}`
14
+ }
15
+ return `${sign}¥${Math.round(abs).toLocaleString("ja-JP")}`
16
+ }
17
+
18
+ function formatPercent(value: number, digits = 1): string {
19
+ return `${value.toFixed(digits)}%`
20
+ }
21
+
22
+ interface TopItemsTableProps {
23
+ data: TopItemRow[]
24
+ }
25
+
26
+ export function TopItemsTable({ data }: TopItemsTableProps) {
27
+ const columns = useMemo<ColumnDef<TopItemRow>[]>(
28
+ () => [
29
+ {
30
+ accessorKey: "name",
31
+ header: "列1",
32
+ },
33
+ {
34
+ accessorKey: "category",
35
+ header: "列2",
36
+ },
37
+ {
38
+ accessorKey: "revenue",
39
+ header: "列3",
40
+ cell: ({ row }) => (
41
+ <div className="flex justify-end">
42
+ <Placeholder>
43
+ {formatCurrency(row.original.revenue, { short: true })}
44
+ </Placeholder>
45
+ </div>
46
+ ),
47
+ },
48
+ {
49
+ accessorKey: "units",
50
+ header: "列4",
51
+ cell: ({ row }) => (
52
+ <div className="flex justify-end">
53
+ <Placeholder>
54
+ {row.original.units.toLocaleString("ja-JP")}
55
+ </Placeholder>
56
+ </div>
57
+ ),
58
+ },
59
+ {
60
+ accessorKey: "share",
61
+ header: "列5",
62
+ cell: ({ row }) => (
63
+ <div className="flex justify-end">
64
+ <Placeholder>{formatPercent(row.original.share * 100)}</Placeholder>
65
+ </div>
66
+ ),
67
+ },
68
+ ],
69
+ [],
70
+ )
71
+
72
+ return <DataTablePreset columns={columns} data={data} enableSorting />
73
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared between the page and `server/api/kpi-summary.ts`.
3
+ *
4
+ * These live in `lib/` rather than `components/` because the client may never
5
+ * import from `server/` (`vantage check` reports `CLIENT_IMPORTS_SERVER`), so
6
+ * anything both sides need has to sit in neutral ground.
7
+ */
8
+
9
+ export type RangeKey = "7d" | "30d" | "90d"
10
+
11
+ export type KpiId = "total-revenue" | "active-users" | "conversion-rate" | "aov"
12
+
13
+ /** Raw numbers only — labels and formatting belong to the page. */
14
+ export interface KpiItem {
15
+ id: KpiId
16
+ value: number
17
+ /** Percent change against the preceding window of the same length. */
18
+ change: number
19
+ sparklineData: number[]
20
+ }
21
+
22
+ export interface TrendPoint {
23
+ date: string
24
+ revenue: number
25
+ orders: number
26
+ visitors: number
27
+ }
28
+
29
+ export interface TopItemRow {
30
+ id: string
31
+ name: string
32
+ category: string
33
+ revenue: number
34
+ units: number
35
+ share: number
36
+ }
37
+
38
+ /** The payload `GET /api/kpi-summary` returns. */
39
+ export interface KpiSummary {
40
+ range: RangeKey
41
+ kpis: KpiItem[]
42
+ trend: TrendPoint[]
43
+ topItems: TopItemRow[]
44
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "kpi-chart-simple-ja",
3
+ "description": "[日本語] KPI + チャート (シンプル) — KPIカード4個・トレンドライン1本・上位アイテムテーブル。server/api から取得し、期間は URL で切り替える最小ダッシュボード構成",
4
+ "version": "0.2.0",
5
+ "files": [
6
+ { "src": "pages/index.tsx", "dest": "index.tsx", "action": "replace" },
7
+ { "src": "components/top-items-table.tsx", "dest": "components/kpi-chart-simple/top-items-table.tsx", "action": "add" },
8
+ { "src": "lib/types.ts", "dest": "lib/kpi-chart-simple/types.ts", "action": "add", "relabel": false },
9
+ { "src": "server/kpi-summary.ts", "dest": "server/api/kpi-summary.ts", "action": "add", "relabel": false }
10
+ ]
11
+ }
@@ -0,0 +1,200 @@
1
+ import { Activity, DollarSign, ShoppingCart, Users } from "lucide-react"
2
+ import { definePage } from "@squadbase/vantage"
3
+ import {
4
+ DashboardCard,
5
+ DashboardCardAction,
6
+ DashboardCardContent,
7
+ DashboardCardHeader,
8
+ DashboardCardPreset,
9
+ DashboardCardTitle,
10
+ EChart,
11
+ PageShell,
12
+ PageShellContent,
13
+ PageShellDescription,
14
+ PageShellHeader,
15
+ PageShellHeaderEnd,
16
+ PageShellHeading,
17
+ PageShellTitle,
18
+ Placeholder,
19
+ SegmentedControl,
20
+ Sparkline,
21
+ } from "@squadbase/vantage/components"
22
+ import type { EChartsOption, SegmentOption } from "@squadbase/vantage/components"
23
+ import { Button, ErrorState, Loading } from "@squadbase/vantage/ui"
24
+ import { useApiQuery } from "@squadbase/vantage/query"
25
+ import { useSearchParam } from "@squadbase/vantage/router"
26
+
27
+ import { TopItemsTable } from "./components/kpi-chart-simple/top-items-table"
28
+ import type { KpiId, KpiSummary, TrendPoint } from "./lib/kpi-chart-simple/types"
29
+
30
+ export const page = definePage({
31
+ title: "KPI + チャート (シンプル)",
32
+ description:
33
+ "KPIカード4個・トレンドライン1本・上位アイテムテーブル。server/api から取得し、期間は URL で切り替える最小構成",
34
+ })
35
+
36
+ // ── 表示フォーマット ─────────────────────────────────────────────────────────
37
+
38
+ function formatNumber(n: number): string {
39
+ const sign = n < 0 ? "-" : ""
40
+ const abs = Math.abs(n)
41
+ if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`
42
+ if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(1)}K`
43
+ return n.toLocaleString("ja-JP")
44
+ }
45
+
46
+ function formatCurrency(n: number): string {
47
+ const sign = n < 0 ? "-" : ""
48
+ const abs = Math.abs(n)
49
+ if (abs >= 1_000_000) return `${sign}¥${(abs / 1_000_000).toFixed(2)}M`
50
+ if (abs >= 1_000) return `${sign}¥${(abs / 1_000).toFixed(1)}K`
51
+ return `${sign}¥${abs.toFixed(0)}`
52
+ }
53
+
54
+ // ── チャートオプション ───────────────────────────────────────────────────────
55
+
56
+ function trendOption(data: TrendPoint[]): EChartsOption {
57
+ return {
58
+ tooltip: { trigger: "axis" },
59
+ legend: { bottom: 0 },
60
+ grid: { left: "3%", right: "4%", bottom: "10%", containLabel: true },
61
+ xAxis: {
62
+ type: "category",
63
+ data: data.map((d) => d.date),
64
+ boundaryGap: false,
65
+ axisLabel: { formatter: (value: string) => value.slice(5) },
66
+ },
67
+ yAxis: {
68
+ type: "value",
69
+ axisLabel: { formatter: (v: number) => formatNumber(v) },
70
+ },
71
+ series: [
72
+ {
73
+ name: "系列A",
74
+ type: "line",
75
+ smooth: true,
76
+ showSymbol: false,
77
+ data: data.map((d) => d.revenue),
78
+ areaStyle: { opacity: 0.18 },
79
+ },
80
+ ],
81
+ }
82
+ }
83
+
84
+ // ── ページ ───────────────────────────────────────────────────────────────────
85
+
86
+ /** 表示ラベルは API のレスポンスではなく描画側に置く。 */
87
+ const KPI_META: Record<
88
+ KpiId,
89
+ { label: string; icon: typeof DollarSign; format: (value: number) => string }
90
+ > = {
91
+ "total-revenue": { label: "指標1", icon: DollarSign, format: formatCurrency },
92
+ "active-users": {
93
+ label: "指標2",
94
+ icon: Users,
95
+ format: (v) => Math.round(v).toLocaleString("ja-JP"),
96
+ },
97
+ "conversion-rate": { label: "指標3", icon: Activity, format: (v) => `${v.toFixed(2)}%` },
98
+ aov: { label: "指標4", icon: ShoppingCart, format: (v) => `¥${v.toFixed(2)}` },
99
+ }
100
+
101
+ const RANGE_OPTIONS: SegmentOption[] = [
102
+ { label: "7日", value: "7d" },
103
+ { label: "30日", value: "30d" },
104
+ { label: "90日", value: "90d" },
105
+ ]
106
+
107
+ function DashboardBody({ data }: { data: KpiSummary }) {
108
+ return (
109
+ <>
110
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
111
+ {data.kpis.map((kpi) => {
112
+ const { label, icon: Icon, format } = KPI_META[kpi.id]
113
+ return (
114
+ <DashboardCard key={kpi.id}>
115
+ <DashboardCardHeader>
116
+ <DashboardCardTitle className="text-muted-foreground">
117
+ {label}
118
+ </DashboardCardTitle>
119
+ <DashboardCardAction>
120
+ <Icon className="size-4 text-muted-foreground" />
121
+ </DashboardCardAction>
122
+ </DashboardCardHeader>
123
+ <DashboardCardContent>
124
+ <Placeholder className="text-2xl font-bold">
125
+ {format(kpi.value)}
126
+ </Placeholder>
127
+ <div className="mt-2">
128
+ <Placeholder className="text-sm font-medium">
129
+ {kpi.change >= 0 ? `+${kpi.change}%` : `${kpi.change}%`}
130
+ </Placeholder>
131
+ </div>
132
+ <Sparkline
133
+ data={kpi.sparklineData.map((v) => ({ value: v }))}
134
+ height={32}
135
+ area
136
+ className="mt-3"
137
+ />
138
+ </DashboardCardContent>
139
+ </DashboardCard>
140
+ )
141
+ })}
142
+ </div>
143
+
144
+ <DashboardCardPreset title="トレンド" description="選択期間の値">
145
+ <EChart option={trendOption(data.trend)} height="320px" />
146
+ </DashboardCardPreset>
147
+
148
+ <DashboardCardPreset title="上位項目" description="値で並べ替え">
149
+ <TopItemsTable data={data.topItems} />
150
+ </DashboardCardPreset>
151
+ </>
152
+ )
153
+ }
154
+
155
+ export default function HomePage() {
156
+ // 絞り込み状態は useState ではなく URL に置く。リロードで消えず、URL を
157
+ // そのまま共有すれば相手も同じ画面を見られる。
158
+ const [range, setRange] = useSearchParam("range", "30d")
159
+
160
+ // search はクエリ文字列とキャッシュキーの両方に入るので、期間を変えると
161
+ // 前の期間のデータを返さずに再取得される。
162
+ const summary = useApiQuery<KpiSummary>("/api/kpi-summary", { search: { range } })
163
+
164
+ return (
165
+ <PageShell>
166
+ <PageShellHeader>
167
+ <PageShellHeading>
168
+ <PageShellTitle>[テンプレート] パフォーマンス概要</PageShellTitle>
169
+ <PageShellDescription>
170
+ 選択期間の主要指標とトレンド。
171
+ </PageShellDescription>
172
+ </PageShellHeading>
173
+ <PageShellHeaderEnd>
174
+ <SegmentedControl
175
+ options={RANGE_OPTIONS}
176
+ value={range}
177
+ onChange={setRange}
178
+ ariaLabel="期間"
179
+ />
180
+ </PageShellHeaderEnd>
181
+ </PageShellHeader>
182
+
183
+ <PageShellContent className="space-y-6">
184
+ {summary.isPending ? (
185
+ <Loading />
186
+ ) : summary.isError ? (
187
+ // summary.error は ApiError。message にはハンドラが HttpError に
188
+ // 渡した文言がそのまま入るので、キャストも汎用文言も要らない。
189
+ <ErrorState
190
+ title="ダッシュボードを読み込めませんでした"
191
+ message={summary.error.message}
192
+ action={<Button onClick={() => void summary.refetch()}>再試行</Button>}
193
+ />
194
+ ) : (
195
+ <DashboardBody data={summary.data} />
196
+ )}
197
+ </PageShellContent>
198
+ </PageShell>
199
+ )
200
+ }