@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,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
+ * 実データに繋ぐときに書き換えるのはこのファイルだけ。「サンプルデータ」以下はクエリの
16
+ * 代役なので、データウェアハウスのクライアント呼び出しに差し替える(認証情報は `ctx.env`
17
+ * から読む。クライアントバンドルには決して入らない)。`KpiSummary` の形さえ保てば
18
+ * ページ側は変更不要。
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
+ // ── サンプルデータ ───────────────────────────────────────────────────────────
28
+ // リクエストごとに揺れないよう決定的に生成する。
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: "項目1", category: "カテゴリA", revenue: 184_320, units: 1_232, share: 0.142 },
49
+ { id: "p-02", name: "項目2", category: "カテゴリB", revenue: 162_480, units: 812, share: 0.125 },
50
+ { id: "p-03", name: "項目3", category: "カテゴリC", revenue: 138_750, units: 463, share: 0.107 },
51
+ { id: "p-04", name: "項目4", category: "カテゴリD", revenue: 121_900, units: 974, share: 0.094 },
52
+ { id: "p-05", name: "項目5", category: "カテゴリD", revenue: 98_640, units: 1_644, share: 0.076 },
53
+ { id: "p-06", name: "項目6", category: "カテゴリB", revenue: 87_220, units: 821, share: 0.067 },
54
+ { id: "p-07", name: "項目7", category: "カテゴリA", revenue: 81_540, units: 679, share: 0.063 },
55
+ { id: "p-08", name: "項目8", category: "カテゴリE", revenue: 72_410, units: 905, share: 0.056 },
56
+ { id: "p-09", name: "項目9", category: "カテゴリE", revenue: 68_900, units: 138, share: 0.053 },
57
+ { id: "p-10", name: "項目10", category: "カテゴリD", revenue: 54_220, units: 1_356, share: 0.042 },
58
+ ]
59
+
60
+ // ── 集計 ─────────────────────────────────────────────────────────────────────
61
+
62
+ function total(rows: TrendPoint[], field: "revenue" | "orders" | "visitors"): number {
63
+ return rows.reduce((acc, row) => acc + row[field], 0)
64
+ }
65
+
66
+ /** 直前の同じ長さの期間に対する変化率(小数第1位まで)。 */
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
+ // ── ハンドラ ─────────────────────────────────────────────────────────────────
119
+
120
+ export async function GET({ request }: ApiContext) {
121
+ const range = new URL(request.url).searchParams.get("range") ?? "30d"
122
+
123
+ // クライアントに見せたいエラーは `HttpError` を throw する。それ以外の throw は
124
+ // ログに記録され、汎用の 500 に丸められる。
125
+ if (!isRangeKey(range)) {
126
+ throw new HttpError(400, `range "${range}" は未対応です。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,88 @@
1
+ import { useMemo } from "react"
2
+
3
+ import { DataTablePreset, Placeholder, type ColumnDef } from "@squadbase/vantage/components"
4
+
5
+ import type { DetailRow } from "./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 >= 1_000_000) return `${sign}$${(abs / 1_000_000).toFixed(2)}M`
12
+ if (abs >= 1_000) return `${sign}$${(abs / 1_000).toFixed(1)}K`
13
+ return `${sign}$${abs.toLocaleString("en-US")}`
14
+ }
15
+ return `${sign}$${Math.round(abs).toLocaleString("en-US")}`
16
+ }
17
+
18
+ function formatPercent(value: number, digits = 1): string {
19
+ return `${value.toFixed(digits)}%`
20
+ }
21
+
22
+ interface DetailTableProps {
23
+ data: DetailRow[]
24
+ }
25
+
26
+ export function DetailTable({ data }: DetailTableProps) {
27
+ const columns = useMemo<ColumnDef<DetailRow>[]>(
28
+ () => [
29
+ {
30
+ accessorKey: "name",
31
+ header: "Column 1",
32
+ },
33
+ {
34
+ accessorKey: "owner",
35
+ header: "Column 2",
36
+ },
37
+ {
38
+ accessorKey: "category",
39
+ header: "Column 3",
40
+ },
41
+ {
42
+ accessorKey: "status",
43
+ header: "Column 4",
44
+ },
45
+ {
46
+ accessorKey: "revenue",
47
+ header: "Column 5",
48
+ cell: ({ row }) => (
49
+ <div className="flex justify-end">
50
+ <Placeholder>
51
+ {formatCurrency(row.original.revenue, { short: true })}
52
+ </Placeholder>
53
+ </div>
54
+ ),
55
+ },
56
+ {
57
+ accessorKey: "units",
58
+ header: "Column 6",
59
+ cell: ({ row }) => (
60
+ <div className="flex justify-end">
61
+ <Placeholder>
62
+ {row.original.units.toLocaleString("en-US")}
63
+ </Placeholder>
64
+ </div>
65
+ ),
66
+ },
67
+ {
68
+ accessorKey: "margin",
69
+ header: "Column 7",
70
+ cell: ({ row }) => (
71
+ <div className="flex justify-end">
72
+ <Placeholder>{formatPercent(row.original.margin)}</Placeholder>
73
+ </div>
74
+ ),
75
+ },
76
+ {
77
+ accessorKey: "updated",
78
+ header: "Column 8",
79
+ cell: ({ row }) => (
80
+ <Placeholder className="text-xs">{row.original.updated}</Placeholder>
81
+ ),
82
+ },
83
+ ],
84
+ [],
85
+ )
86
+
87
+ return <DataTablePreset columns={columns} data={data} enableSorting />
88
+ }
@@ -0,0 +1,69 @@
1
+ import type { DetailRow, SummaryRow, TrendPoint } from "./types"
2
+
3
+ export const detailRows: DetailRow[] = [
4
+ { id: "row-01", name: "Item 1", owner: "Owner 1", status: "active", category: "Category 1", revenue: 111569, units: 630, margin: 24.7, updated: "2024-03-26" },
5
+ { id: "row-02", name: "Item 2", owner: "Owner 2", status: "active", category: "Category 2", revenue: 64510, units: 1418, margin: 42.8, updated: "2024-03-24" },
6
+ { id: "row-03", name: "Item 3", owner: "Owner 3", status: "active", category: "Category 3", revenue: 178768, units: 700, margin: 27.3, updated: "2024-03-29" },
7
+ { id: "row-04", name: "Item 4", owner: "Owner 4", status: "paused", category: "Category 4", revenue: 108664, units: 541, margin: 27.4, updated: "2024-03-24" },
8
+ { id: "row-05", name: "Item 5", owner: "Owner 5", status: "draft", category: "Category 5", revenue: 89259, units: 453, margin: 41.7, updated: "2024-03-25" },
9
+ { id: "row-06", name: "Item 6", owner: "Owner 6", status: "active", category: "Category 1", revenue: 171912, units: 1271, margin: 35.2, updated: "2024-03-31" },
10
+ { id: "row-07", name: "Item 7", owner: "Owner 1", status: "active", category: "Category 2", revenue: 46499, units: 1024, margin: 43.3, updated: "2024-03-18" },
11
+ { id: "row-08", name: "Item 8", owner: "Owner 2", status: "active", category: "Category 3", revenue: 90305, units: 624, margin: 39.3, updated: "2024-03-20" },
12
+ { id: "row-09", name: "Item 9", owner: "Owner 3", status: "paused", category: "Category 4", revenue: 123575, units: 804, margin: 49.1, updated: "2024-03-27" },
13
+ { id: "row-10", name: "Item 10", owner: "Owner 4", status: "draft", category: "Category 5", revenue: 191742, units: 2477, margin: 29.4, updated: "2024-03-26" },
14
+ { id: "row-11", name: "Item 11", owner: "Owner 5", status: "active", category: "Category 1", revenue: 174313, units: 682, margin: 25.1, updated: "2024-03-26" },
15
+ { id: "row-12", name: "Item 12", owner: "Owner 6", status: "active", category: "Category 2", revenue: 140422, units: 592, margin: 26.2, updated: "2024-03-21" },
16
+ { id: "row-13", name: "Item 13", owner: "Owner 1", status: "active", category: "Category 3", revenue: 171059, units: 765, margin: 39.3, updated: "2024-03-26" },
17
+ { id: "row-14", name: "Item 14", owner: "Owner 2", status: "paused", category: "Category 4", revenue: 127951, units: 542, margin: 20.4, updated: "2024-03-20" },
18
+ { id: "row-15", name: "Item 15", owner: "Owner 3", status: "draft", category: "Category 5", revenue: 169122, units: 1824, margin: 19.8, updated: "2024-03-20" },
19
+ { id: "row-16", name: "Item 16", owner: "Owner 4", status: "active", category: "Category 1", revenue: 91117, units: 479, margin: 30.2, updated: "2024-03-21" },
20
+ { id: "row-17", name: "Item 17", owner: "Owner 5", status: "active", category: "Category 2", revenue: 57885, units: 249, margin: 46.5, updated: "2024-03-21" },
21
+ { id: "row-18", name: "Item 18", owner: "Owner 6", status: "active", category: "Category 3", revenue: 156340, units: 1804, margin: 19.5, updated: "2024-03-27" },
22
+ { id: "row-19", name: "Item 19", owner: "Owner 1", status: "paused", category: "Category 4", revenue: 58582, units: 471, margin: 23.5, updated: "2024-03-31" },
23
+ { id: "row-20", name: "Item 20", owner: "Owner 2", status: "draft", category: "Category 5", revenue: 70782, units: 411, margin: 28.7, updated: "2024-03-19" },
24
+ { id: "row-21", name: "Item 21", owner: "Owner 3", status: "active", category: "Category 1", revenue: 48743, units: 334, margin: 46.3, updated: "2024-03-18" },
25
+ { id: "row-22", name: "Item 22", owner: "Owner 4", status: "active", category: "Category 2", revenue: 180121, units: 1042, margin: 40.5, updated: "2024-03-18" },
26
+ { id: "row-23", name: "Item 23", owner: "Owner 5", status: "active", category: "Category 3", revenue: 113309, units: 562, margin: 20.4, updated: "2024-03-19" },
27
+ { id: "row-24", name: "Item 24", owner: "Owner 6", status: "paused", category: "Category 4", revenue: 112999, units: 574, margin: 25.8, updated: "2024-03-27" },
28
+ ]
29
+
30
+ export const summaryRows: SummaryRow[] = [
31
+ { segment: "Category 3", count: 5, revenue: 709781, avgMargin: 29.2, share: 0.2491 },
32
+ { segment: "Category 1", count: 5, revenue: 597654, avgMargin: 32.3, share: 0.2097 },
33
+ { segment: "Category 4", count: 5, revenue: 531771, avgMargin: 29.2, share: 0.1866 },
34
+ { segment: "Category 5", count: 4, revenue: 520905, avgMargin: 29.9, share: 0.1828 },
35
+ { segment: "Category 2", count: 5, revenue: 489437, avgMargin: 39.9, share: 0.1718 },
36
+ ]
37
+
38
+ export const trendSeries: TrendPoint[] = [
39
+ { date: "2024-03-02", value: 20590 },
40
+ { date: "2024-03-03", value: 19289 },
41
+ { date: "2024-03-04", value: 23339 },
42
+ { date: "2024-03-05", value: 20609 },
43
+ { date: "2024-03-06", value: 23415 },
44
+ { date: "2024-03-07", value: 25849 },
45
+ { date: "2024-03-08", value: 25114 },
46
+ { date: "2024-03-09", value: 22860 },
47
+ { date: "2024-03-10", value: 24511 },
48
+ { date: "2024-03-11", value: 24606 },
49
+ { date: "2024-03-12", value: 26125 },
50
+ { date: "2024-03-13", value: 21827 },
51
+ { date: "2024-03-14", value: 27583 },
52
+ { date: "2024-03-15", value: 23709 },
53
+ { date: "2024-03-16", value: 26295 },
54
+ { date: "2024-03-17", value: 24547 },
55
+ { date: "2024-03-18", value: 24112 },
56
+ { date: "2024-03-19", value: 28416 },
57
+ { date: "2024-03-20", value: 23997 },
58
+ { date: "2024-03-21", value: 23835 },
59
+ { date: "2024-03-22", value: 24690 },
60
+ { date: "2024-03-23", value: 28432 },
61
+ { date: "2024-03-24", value: 25377 },
62
+ { date: "2024-03-25", value: 29616 },
63
+ { date: "2024-03-26", value: 28354 },
64
+ { date: "2024-03-27", value: 25239 },
65
+ { date: "2024-03-28", value: 27696 },
66
+ { date: "2024-03-29", value: 26262 },
67
+ { date: "2024-03-30", value: 25920 },
68
+ { date: "2024-03-31", value: 27428 },
69
+ ]
@@ -0,0 +1,24 @@
1
+ export interface DetailRow {
2
+ id: string
3
+ name: string
4
+ owner: string
5
+ status: "active" | "paused" | "draft"
6
+ category: string
7
+ revenue: number
8
+ units: number
9
+ margin: number
10
+ updated: string
11
+ }
12
+
13
+ export interface SummaryRow {
14
+ segment: string
15
+ count: number
16
+ revenue: number
17
+ avgMargin: number
18
+ share: number
19
+ }
20
+
21
+ export interface TrendPoint {
22
+ date: string
23
+ value: number
24
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "table-focus",
3
+ "description": "Table-Focused — Workbench layout: header search + Export, status filter chips, detail table (24+ rows, sortable) as the main element, with a trend chart and segment breakdown in a right sidebar. For data-heavy operational dashboards.",
4
+ "version": "0.1.0",
5
+ "files": [
6
+ { "src": "pages/index.tsx", "dest": "index.tsx", "action": "replace" },
7
+ { "src": "components/detail-table.tsx", "dest": "components/table-focus/detail-table.tsx", "action": "add" },
8
+ { "src": "lib/mock-data.ts", "dest": "components/table-focus/mock-data.ts", "action": "add", "relabel": false },
9
+ { "src": "lib/types.ts", "dest": "components/table-focus/types.ts", "action": "add", "relabel": false }
10
+ ]
11
+ }
@@ -0,0 +1,180 @@
1
+ import { useMemo, useState } from "react"
2
+ import { Download, Search } from "lucide-react"
3
+
4
+ import { definePage } from "@squadbase/vantage"
5
+ import {
6
+ DashboardCardPreset,
7
+ EChart,
8
+ PageShell,
9
+ PageShellContent,
10
+ PageShellDescription,
11
+ PageShellHeader,
12
+ PageShellHeaderEnd,
13
+ PageShellHeading,
14
+ PageShellTitle,
15
+ Placeholder,
16
+ type EChartsOption,
17
+ } from "@squadbase/vantage/components"
18
+ import { Button, Input, ToggleGroup, ToggleGroupItem } from "@squadbase/vantage/ui"
19
+
20
+ import { DetailTable } from "./components/table-focus/detail-table"
21
+ import {
22
+ detailRows,
23
+ summaryRows,
24
+ trendSeries,
25
+ } from "./components/table-focus/mock-data"
26
+ import type { DetailRow, TrendPoint } from "./components/table-focus/types"
27
+
28
+ export const page = definePage({
29
+ title: "Records",
30
+ description:
31
+ "Workbench layout: header search + Export, status filter chips, a sortable detail table as the main element, with a trend chart and segment breakdown in a right sidebar.",
32
+ })
33
+
34
+ // ── chart helpers ───────────────────────────────────────────────────────────
35
+
36
+ function getBaseGrid() {
37
+ return { left: "3%", right: "4%", bottom: "10%", containLabel: true }
38
+ }
39
+
40
+ function formatNumber(n: number): string {
41
+ const sign = n < 0 ? "-" : ""
42
+ const abs = Math.abs(n)
43
+ if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`
44
+ if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(1)}K`
45
+ return n.toLocaleString("en-US")
46
+ }
47
+
48
+ // ── chart options ────────────────────────────────────────────────────────────
49
+
50
+ function trendOption(data: TrendPoint[]): EChartsOption {
51
+ return {
52
+ tooltip: { trigger: "axis" },
53
+ legend: { bottom: 0 },
54
+ grid: getBaseGrid(),
55
+ xAxis: {
56
+ type: "category",
57
+ data: data.map((d) => d.date),
58
+ boundaryGap: false,
59
+ axisLabel: { formatter: (v: string) => v.slice(5) },
60
+ },
61
+ yAxis: { type: "value", axisLabel: { formatter: (v: number) => formatNumber(v) } },
62
+ series: [
63
+ {
64
+ name: "Series A",
65
+ type: "line",
66
+ smooth: true,
67
+ showSymbol: false,
68
+ data: data.map((d) => d.value),
69
+ areaStyle: { opacity: 0.18 },
70
+ },
71
+ ],
72
+ }
73
+ }
74
+
75
+ // ── page ─────────────────────────────────────────────────────────────────────
76
+
77
+ type StatusFilter = "all" | DetailRow["status"]
78
+
79
+ export default function HomePage() {
80
+ const [query, setQuery] = useState("")
81
+ const [status, setStatus] = useState<StatusFilter>("all")
82
+
83
+ const filtered = useMemo(() => {
84
+ const q = query.trim().toLowerCase()
85
+ return detailRows.filter((r) => {
86
+ if (status !== "all" && r.status !== status) return false
87
+ if (q && !r.name.toLowerCase().includes(q) && !r.owner.toLowerCase().includes(q)) return false
88
+ return true
89
+ })
90
+ }, [query, status])
91
+
92
+ return (
93
+ <PageShell>
94
+ <PageShellHeader>
95
+ <PageShellHeading>
96
+ <PageShellTitle>[Template] Records</PageShellTitle>
97
+ <PageShellDescription>
98
+ Browse, filter, and search the full record list.
99
+ </PageShellDescription>
100
+ </PageShellHeading>
101
+ <PageShellHeaderEnd className="flex-row items-center gap-2">
102
+ <div className="relative">
103
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
104
+ <Input
105
+ value={query}
106
+ onChange={(e) => setQuery(e.target.value)}
107
+ placeholder="Search..."
108
+ className="h-9 w-64 pl-8"
109
+ />
110
+ </div>
111
+ <Button variant="outline" size="sm" className="gap-1.5">
112
+ <Download className="size-4" />
113
+ </Button>
114
+ </PageShellHeaderEnd>
115
+ </PageShellHeader>
116
+
117
+ <PageShellContent className="space-y-4">
118
+ <div className="flex flex-wrap items-center justify-between gap-3">
119
+ {/* Base UI ToggleGroup is array-valued even for single select. */}
120
+ <ToggleGroup
121
+ value={[status]}
122
+ onValueChange={(values) => {
123
+ const next = values[0]
124
+ if (next) setStatus(next as StatusFilter)
125
+ }}
126
+ variant="outline"
127
+ size="sm"
128
+ >
129
+ <ToggleGroupItem value="all">All</ToggleGroupItem>
130
+ <ToggleGroupItem value="active">Option A</ToggleGroupItem>
131
+ <ToggleGroupItem value="paused">Option B</ToggleGroupItem>
132
+ <ToggleGroupItem value="draft">Option C</ToggleGroupItem>
133
+ </ToggleGroup>
134
+ <span className="text-xs text-muted-foreground">
135
+ {filtered.length} results
136
+ </span>
137
+ </div>
138
+
139
+ <div className="grid gap-4 lg:grid-cols-3">
140
+ <div className="lg:col-span-2">
141
+ <DashboardCardPreset
142
+ title="Records"
143
+ description="Detailed rows for the current filter"
144
+ >
145
+ <DetailTable data={filtered} />
146
+ </DashboardCardPreset>
147
+ </div>
148
+ <div className="space-y-4">
149
+ <DashboardCardPreset
150
+ title="Trend"
151
+ description="Values over the selected period"
152
+ >
153
+ <EChart option={trendOption(trendSeries)} height="220px" />
154
+ </DashboardCardPreset>
155
+ <DashboardCardPreset title="By segment" description="Share of total">
156
+ <div className="space-y-3">
157
+ {summaryRows.map((row) => (
158
+ <div key={row.segment} className="space-y-1.5">
159
+ <div className="flex items-baseline justify-between">
160
+ <span className="text-sm font-medium">{row.segment}</span>
161
+ <Placeholder className="text-sm">
162
+ {row.revenue.toLocaleString("en-US")}
163
+ </Placeholder>
164
+ </div>
165
+ <div className="h-1.5 overflow-hidden rounded-full bg-muted">
166
+ <div
167
+ className="h-full rounded-full bg-primary"
168
+ style={{ width: `${(row.share * 100).toFixed(1)}%` }}
169
+ />
170
+ </div>
171
+ </div>
172
+ ))}
173
+ </div>
174
+ </DashboardCardPreset>
175
+ </div>
176
+ </div>
177
+ </PageShellContent>
178
+ </PageShell>
179
+ )
180
+ }
@@ -0,0 +1,95 @@
1
+ import { useMemo } from "react"
2
+
3
+ import { DataTablePreset, Placeholder, type ColumnDef } from "@squadbase/vantage/components"
4
+
5
+ import type { DetailRow } from "./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
+ const STATUS_LABELS: Record<DetailRow["status"], string> = {
23
+ active: "有効",
24
+ paused: "一時停止",
25
+ draft: "下書き",
26
+ }
27
+
28
+ interface DetailTableProps {
29
+ data: DetailRow[]
30
+ }
31
+
32
+ export function DetailTable({ data }: DetailTableProps) {
33
+ const columns = useMemo<ColumnDef<DetailRow>[]>(
34
+ () => [
35
+ {
36
+ accessorKey: "name",
37
+ header: "列1",
38
+ },
39
+ {
40
+ accessorKey: "owner",
41
+ header: "列2",
42
+ },
43
+ {
44
+ accessorKey: "category",
45
+ header: "列3",
46
+ },
47
+ {
48
+ accessorKey: "status",
49
+ header: "列4",
50
+ cell: ({ row }) => STATUS_LABELS[row.original.status],
51
+ },
52
+ {
53
+ accessorKey: "revenue",
54
+ header: "列5",
55
+ cell: ({ row }) => (
56
+ <div className="flex justify-end">
57
+ <Placeholder>
58
+ {formatCurrency(row.original.revenue, { short: true })}
59
+ </Placeholder>
60
+ </div>
61
+ ),
62
+ },
63
+ {
64
+ accessorKey: "units",
65
+ header: "列6",
66
+ cell: ({ row }) => (
67
+ <div className="flex justify-end">
68
+ <Placeholder>
69
+ {row.original.units.toLocaleString("ja-JP")}
70
+ </Placeholder>
71
+ </div>
72
+ ),
73
+ },
74
+ {
75
+ accessorKey: "margin",
76
+ header: "列7",
77
+ cell: ({ row }) => (
78
+ <div className="flex justify-end">
79
+ <Placeholder>{formatPercent(row.original.margin)}</Placeholder>
80
+ </div>
81
+ ),
82
+ },
83
+ {
84
+ accessorKey: "updated",
85
+ header: "列8",
86
+ cell: ({ row }) => (
87
+ <Placeholder className="text-xs">{row.original.updated}</Placeholder>
88
+ ),
89
+ },
90
+ ],
91
+ [],
92
+ )
93
+
94
+ return <DataTablePreset columns={columns} data={data} enableSorting />
95
+ }