@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.
- package/README.md +112 -0
- package/base-template/.squadbase/skills/vantage-add-feature/SKILL.md +162 -0
- package/base-template/.squadbase/skills/vantage-app/SKILL.md +120 -0
- package/base-template/.squadbase/skills/vantage-pitfalls/SKILL.md +137 -0
- package/base-template/AGENTS.md +339 -0
- package/base-template/_404.tsx +18 -0
- package/base-template/_error.tsx +14 -0
- package/base-template/_gitignore +5 -0
- package/base-template/_layout.tsx +60 -0
- package/base-template/components/user-card.tsx +132 -0
- package/base-template/index.tsx +59 -0
- package/base-template/package.json +27 -0
- package/base-template/server/api/health.ts +12 -0
- package/base-template/squadbase.yml +5 -0
- package/base-template/styles.css +18 -0
- package/base-template/tsconfig.json +28 -0
- package/chart-presets/berry.css +16 -0
- package/chart-presets/blue.css +16 -0
- package/chart-presets/forest.css +16 -0
- package/chart-presets/mono.css +16 -0
- package/chart-presets/ocean.css +16 -0
- package/chart-presets/sunset.css +16 -0
- package/dist/index.js +1157 -0
- package/package.json +55 -0
- package/ui-templates/CLAUDE.md +200 -0
- package/ui-templates/blank/manifest.json +8 -0
- package/ui-templates/blank/pages/index.tsx +33 -0
- package/ui-templates/blank-ja/manifest.json +8 -0
- package/ui-templates/blank-ja/pages/index.tsx +33 -0
- package/ui-templates/chart-grid/lib/mock-data.ts +139 -0
- package/ui-templates/chart-grid/lib/types.ts +31 -0
- package/ui-templates/chart-grid/manifest.json +10 -0
- package/ui-templates/chart-grid/pages/index.tsx +331 -0
- package/ui-templates/chart-grid/preview-square.png +0 -0
- package/ui-templates/chart-grid/preview-wide.png +0 -0
- package/ui-templates/chart-grid-ja/lib/mock-data.ts +139 -0
- package/ui-templates/chart-grid-ja/lib/types.ts +31 -0
- package/ui-templates/chart-grid-ja/manifest.json +10 -0
- package/ui-templates/chart-grid-ja/pages/index.tsx +331 -0
- package/ui-templates/chart-grid-ja/preview-square.png +0 -0
- package/ui-templates/chart-grid-ja/preview-wide.png +0 -0
- package/ui-templates/funnel/components/chart-helpers.ts +11 -0
- package/ui-templates/funnel/components/stage-table.tsx +63 -0
- package/ui-templates/funnel/lib/mock-data.ts +22 -0
- package/ui-templates/funnel/lib/types.ts +11 -0
- package/ui-templates/funnel/manifest.json +12 -0
- package/ui-templates/funnel/pages/index.tsx +252 -0
- package/ui-templates/funnel/preview-square.png +0 -0
- package/ui-templates/funnel/preview-wide.png +0 -0
- package/ui-templates/funnel-ja/components/chart-helpers.ts +11 -0
- package/ui-templates/funnel-ja/components/stage-table.tsx +63 -0
- package/ui-templates/funnel-ja/lib/mock-data.ts +22 -0
- package/ui-templates/funnel-ja/lib/types.ts +11 -0
- package/ui-templates/funnel-ja/manifest.json +12 -0
- package/ui-templates/funnel-ja/pages/index.tsx +251 -0
- package/ui-templates/funnel-ja/preview-square.png +0 -0
- package/ui-templates/funnel-ja/preview-wide.png +0 -0
- package/ui-templates/kpi-chart-advanced/components/campaign-table.tsx +69 -0
- package/ui-templates/kpi-chart-advanced/lib/mock-data.ts +109 -0
- package/ui-templates/kpi-chart-advanced/lib/types.ts +38 -0
- package/ui-templates/kpi-chart-advanced/manifest.json +11 -0
- package/ui-templates/kpi-chart-advanced/pages/index.tsx +339 -0
- package/ui-templates/kpi-chart-advanced/preview-square.png +0 -0
- package/ui-templates/kpi-chart-advanced/preview-wide.png +0 -0
- package/ui-templates/kpi-chart-advanced-ja/components/campaign-table.tsx +69 -0
- package/ui-templates/kpi-chart-advanced-ja/lib/mock-data.ts +109 -0
- package/ui-templates/kpi-chart-advanced-ja/lib/types.ts +38 -0
- package/ui-templates/kpi-chart-advanced-ja/manifest.json +11 -0
- package/ui-templates/kpi-chart-advanced-ja/pages/index.tsx +333 -0
- package/ui-templates/kpi-chart-advanced-ja/preview-square.png +0 -0
- package/ui-templates/kpi-chart-advanced-ja/preview-wide.png +0 -0
- package/ui-templates/kpi-chart-simple/components/top-items-table.tsx +73 -0
- package/ui-templates/kpi-chart-simple/lib/types.ts +44 -0
- package/ui-templates/kpi-chart-simple/manifest.json +11 -0
- package/ui-templates/kpi-chart-simple/pages/index.tsx +200 -0
- package/ui-templates/kpi-chart-simple/preview-square.png +0 -0
- package/ui-templates/kpi-chart-simple/preview-wide.png +0 -0
- package/ui-templates/kpi-chart-simple/server/kpi-summary.ts +141 -0
- package/ui-templates/kpi-chart-simple-ja/components/top-items-table.tsx +73 -0
- package/ui-templates/kpi-chart-simple-ja/lib/types.ts +44 -0
- package/ui-templates/kpi-chart-simple-ja/manifest.json +11 -0
- package/ui-templates/kpi-chart-simple-ja/pages/index.tsx +200 -0
- package/ui-templates/kpi-chart-simple-ja/preview-square.png +0 -0
- package/ui-templates/kpi-chart-simple-ja/preview-wide.png +0 -0
- package/ui-templates/kpi-chart-simple-ja/server/kpi-summary.ts +141 -0
- package/ui-templates/table-focus/components/detail-table.tsx +88 -0
- package/ui-templates/table-focus/lib/mock-data.ts +69 -0
- package/ui-templates/table-focus/lib/types.ts +24 -0
- package/ui-templates/table-focus/manifest.json +11 -0
- package/ui-templates/table-focus/pages/index.tsx +180 -0
- package/ui-templates/table-focus/preview-square.png +0 -0
- package/ui-templates/table-focus/preview-wide.png +0 -0
- package/ui-templates/table-focus-ja/components/detail-table.tsx +95 -0
- package/ui-templates/table-focus-ja/lib/mock-data.ts +69 -0
- package/ui-templates/table-focus-ja/lib/types.ts +24 -0
- package/ui-templates/table-focus-ja/manifest.json +11 -0
- package/ui-templates/table-focus-ja/pages/index.tsx +180 -0
- package/ui-templates/table-focus-ja/preview-square.png +0 -0
- 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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
}
|