@definite-app/data-apps 1.0.0
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/CLAUDE.md +686 -0
- package/LICENSE +201 -0
- package/README.md +643 -0
- package/build.mjs +459 -0
- package/examples/_refined_demo/app.json +15 -0
- package/examples/_refined_demo/data/sample.parquet +0 -0
- package/examples/_refined_demo/gen_preview_data.py +59 -0
- package/examples/_refined_demo/preview-data.json +13 -0
- package/examples/_refined_demo/src/App.tsx +188 -0
- package/examples/_refined_demo/src/main.tsx +12 -0
- package/examples/loan-portfolio/app.json +31 -0
- package/examples/loan-portfolio/data/loan_book.parquet +0 -0
- package/examples/loan-portfolio/gen_preview_data.py +454 -0
- package/examples/loan-portfolio/preview-data.json +84 -0
- package/examples/loan-portfolio/src/App.tsx +1103 -0
- package/examples/loan-portfolio/src/main.tsx +12 -0
- package/examples/revenue-explorer/app.json +23 -0
- package/examples/revenue-explorer/data/transactions.parquet +0 -0
- package/examples/revenue-explorer/gen_preview_data.py +129 -0
- package/examples/revenue-explorer/preview-data.json +49 -0
- package/examples/revenue-explorer/src/App.tsx +527 -0
- package/examples/revenue-explorer/src/main.tsx +12 -0
- package/package.json +55 -0
- package/preview.mjs +35 -0
- package/runtime/definite-runtime.tsx +5934 -0
- package/scripts/headless-smoke.mjs +196 -0
- package/templates/blank/app.json +15 -0
- package/templates/blank/src/App.tsx +41 -0
- package/templates/blank/src/main.tsx +12 -0
- package/templates/refined/app.json +15 -0
- package/templates/refined/src/App.tsx +198 -0
- package/templates/refined/src/main.tsx +12 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import React, { useDeferredValue, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AppShell,
|
|
5
|
+
Badge,
|
|
6
|
+
Card,
|
|
7
|
+
DataTable,
|
|
8
|
+
DateRangeFilter,
|
|
9
|
+
DEFAULT_DATE_RANGE_PRESETS,
|
|
10
|
+
type DateRangeValue,
|
|
11
|
+
EChart,
|
|
12
|
+
ErrorState,
|
|
13
|
+
FilterPills,
|
|
14
|
+
IssueBanner,
|
|
15
|
+
KpiCard,
|
|
16
|
+
LoadingState,
|
|
17
|
+
MultiSelect,
|
|
18
|
+
PerspectivePanel,
|
|
19
|
+
ReportTable,
|
|
20
|
+
ResourceCacheBadge,
|
|
21
|
+
Select,
|
|
22
|
+
TabGroup,
|
|
23
|
+
TextInput,
|
|
24
|
+
Tooltip,
|
|
25
|
+
useDataset,
|
|
26
|
+
useJsonResource,
|
|
27
|
+
usePerspective,
|
|
28
|
+
useSqlQuery,
|
|
29
|
+
useTheme,
|
|
30
|
+
} from "@definite/runtime";
|
|
31
|
+
|
|
32
|
+
type BranchOption = {
|
|
33
|
+
branchId: string;
|
|
34
|
+
branchName: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type AppIssue = {
|
|
38
|
+
key: string;
|
|
39
|
+
title: string;
|
|
40
|
+
message: string;
|
|
41
|
+
severity: "warning";
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const searchIcon = (
|
|
45
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
46
|
+
<circle cx="11" cy="11" r="8" />
|
|
47
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
48
|
+
</svg>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
function escapeSql(value: string): string {
|
|
52
|
+
return value.replace(/'/g, "''");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function App() {
|
|
56
|
+
const { theme, toggleTheme } = useTheme();
|
|
57
|
+
const transactions = useDataset("transactions");
|
|
58
|
+
const branches = useJsonResource<BranchOption[]>("branches");
|
|
59
|
+
const [selectedBranches, setSelectedBranches] = useState<BranchOption[]>([]);
|
|
60
|
+
const [statusFilter, setStatusFilter] = useState("");
|
|
61
|
+
const [activeTab, setActiveTab] = useState("Dashboard");
|
|
62
|
+
const [search, setSearch] = useState("");
|
|
63
|
+
const [reportFilter, setReportFilter] = useState("All");
|
|
64
|
+
const [reportRange, setReportRange] = useState<DateRangeValue>(() =>
|
|
65
|
+
DEFAULT_DATE_RANGE_PRESETS.find((p) => p.key === "last90")!.compute(),
|
|
66
|
+
);
|
|
67
|
+
const deferredSearch = useDeferredValue(search);
|
|
68
|
+
const perspective = usePerspective(transactions);
|
|
69
|
+
|
|
70
|
+
// Build SQL WHERE clause from active filters
|
|
71
|
+
const whereClause = useMemo(() => {
|
|
72
|
+
const clauses: string[] = [];
|
|
73
|
+
if (selectedBranches.length > 0) {
|
|
74
|
+
const names = selectedBranches.map((b) => `'${escapeSql(b.branchName)}'`).join(", ");
|
|
75
|
+
clauses.push(`branchName IN (${names})`);
|
|
76
|
+
}
|
|
77
|
+
if (statusFilter) {
|
|
78
|
+
clauses.push(`status = '${escapeSql(statusFilter)}'`);
|
|
79
|
+
}
|
|
80
|
+
return clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
81
|
+
}, [selectedBranches, statusFilter]);
|
|
82
|
+
|
|
83
|
+
// Build Perspective filter array from active filters
|
|
84
|
+
const perspectiveFilters = useMemo(() => {
|
|
85
|
+
const filters: Array<[string, string, unknown]> = [];
|
|
86
|
+
if (selectedBranches.length === 1) {
|
|
87
|
+
filters.push(["branchName", "==", selectedBranches[0].branchName]);
|
|
88
|
+
} else if (selectedBranches.length > 1) {
|
|
89
|
+
filters.push(["branchName", "in", selectedBranches.map((b) => b.branchName)]);
|
|
90
|
+
}
|
|
91
|
+
if (statusFilter) {
|
|
92
|
+
filters.push(["status", "==", statusFilter]);
|
|
93
|
+
}
|
|
94
|
+
return filters;
|
|
95
|
+
}, [selectedBranches, statusFilter]);
|
|
96
|
+
|
|
97
|
+
// Filtered KPI metrics
|
|
98
|
+
const metrics = useSqlQuery(
|
|
99
|
+
transactions,
|
|
100
|
+
transactions.tableRef
|
|
101
|
+
? `SELECT COUNT(*)::INTEGER AS rowCount, COALESCE(SUM(amount), 0)::DOUBLE AS totalAmount, COALESCE(AVG(amount), 0)::DOUBLE AS averageAmount FROM ${transactions.tableRef}${whereClause}`
|
|
102
|
+
: "",
|
|
103
|
+
[whereClause],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Report tab: WHERE clause from date range + segment
|
|
107
|
+
const reportWhere = useMemo(() => {
|
|
108
|
+
const clauses: string[] = [];
|
|
109
|
+
if (reportRange.from) clauses.push(`transactionDate >= '${escapeSql(reportRange.from)}'`);
|
|
110
|
+
if (reportRange.to) clauses.push(`transactionDate <= '${escapeSql(reportRange.to)}'`);
|
|
111
|
+
if (reportFilter && reportFilter !== "All") clauses.push(`status = '${escapeSql(reportFilter)}'`);
|
|
112
|
+
return clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
113
|
+
}, [reportRange.from, reportRange.to, reportFilter]);
|
|
114
|
+
|
|
115
|
+
const reportAgg = useSqlQuery<Array<{ branchName: string; units: number; volume: number }>>(
|
|
116
|
+
transactions,
|
|
117
|
+
transactions.tableRef
|
|
118
|
+
? `SELECT branchName, COUNT(*)::INTEGER AS units, COALESCE(SUM(amount), 0)::DOUBLE AS volume FROM ${transactions.tableRef}${reportWhere} GROUP BY branchName ORDER BY branchName`
|
|
119
|
+
: "",
|
|
120
|
+
[reportWhere],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Filtered recent rows
|
|
124
|
+
const recentRows = useSqlQuery(
|
|
125
|
+
transactions,
|
|
126
|
+
transactions.tableRef
|
|
127
|
+
? `SELECT transactionDate, branchName, status, amount FROM ${transactions.tableRef}${whereClause} ORDER BY transactionDate DESC LIMIT 20`
|
|
128
|
+
: "",
|
|
129
|
+
[whereClause],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const transactionRowCount = typeof metrics.data?.[0]?.rowCount === "number"
|
|
133
|
+
? metrics.data[0].rowCount
|
|
134
|
+
: transactions.cache?.rowCount ?? null;
|
|
135
|
+
const appIssues: AppIssue[] = [
|
|
136
|
+
branches.error
|
|
137
|
+
? { key: "branches", title: "Branch directory unavailable", message: branches.error, severity: "warning" as const }
|
|
138
|
+
: null,
|
|
139
|
+
metrics.error
|
|
140
|
+
? { key: "metrics", title: "KPI metrics unavailable", message: metrics.error, severity: "warning" as const }
|
|
141
|
+
: null,
|
|
142
|
+
recentRows.error
|
|
143
|
+
? { key: "recentRows", title: "Recent transactions unavailable", message: recentRows.error, severity: "warning" as const }
|
|
144
|
+
: null,
|
|
145
|
+
perspective.error
|
|
146
|
+
? { key: "perspective", title: "Charts unavailable", message: perspective.error, severity: "warning" as const }
|
|
147
|
+
: null,
|
|
148
|
+
].filter((issue): issue is AppIssue => issue !== null);
|
|
149
|
+
|
|
150
|
+
const filteredBranches = (branches.data ?? [])
|
|
151
|
+
.filter((branch) => {
|
|
152
|
+
if (!deferredSearch) return true;
|
|
153
|
+
return branch.branchName.toLowerCase().includes(deferredSearch.toLowerCase());
|
|
154
|
+
})
|
|
155
|
+
.slice(0, 25);
|
|
156
|
+
|
|
157
|
+
// Drill-down handlers: click a bar to toggle the corresponding filter
|
|
158
|
+
const handleBranchDrill = (row: Record<string, unknown> | null) => {
|
|
159
|
+
if (!row) return;
|
|
160
|
+
const value = String(row.branchName ?? row[Object.keys(row)[0]] ?? "");
|
|
161
|
+
if (!value) return;
|
|
162
|
+
// Toggle: if already the only selected branch, clear it
|
|
163
|
+
if (selectedBranches.length === 1 && selectedBranches[0].branchName === value) {
|
|
164
|
+
setSelectedBranches([]);
|
|
165
|
+
} else {
|
|
166
|
+
const match = (branches.data ?? []).find((b) => b.branchName === value);
|
|
167
|
+
if (match) setSelectedBranches([match]);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handleStatusDrill = (row: Record<string, unknown> | null) => {
|
|
172
|
+
if (!row) return;
|
|
173
|
+
const value = String(row.status ?? row[Object.keys(row)[0]] ?? "");
|
|
174
|
+
if (!value) return;
|
|
175
|
+
setStatusFilter((prev) => (prev === value ? "" : value));
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
if (transactions.loading) {
|
|
179
|
+
return <LoadingState message="Loading DuckDB, dataset, and Perspective runtime..." />;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (transactions.error) {
|
|
183
|
+
return <ErrorState title="Dataset failed to load" message={transactions.error} />;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const hasFilters = selectedBranches.length > 0 || statusFilter !== "";
|
|
187
|
+
const kpis: Array<{ title: string; value: unknown; format: "number" | "currency"; detail?: React.ReactNode }> = [
|
|
188
|
+
{ title: "Transactions", value: metrics.data?.[0]?.rowCount ?? 0, format: "number" },
|
|
189
|
+
{
|
|
190
|
+
title: "Total Revenue",
|
|
191
|
+
value: metrics.data?.[0]?.totalAmount ?? 0,
|
|
192
|
+
format: "currency",
|
|
193
|
+
detail: (
|
|
194
|
+
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
|
195
|
+
B: <span style={{ color: "#4ade80", fontWeight: 600 }}>+8.2%</span>
|
|
196
|
+
{" | "}
|
|
197
|
+
LY: <span style={{ color: "#f87171", fontWeight: 600 }}>-3.1%</span>
|
|
198
|
+
</span>
|
|
199
|
+
),
|
|
200
|
+
},
|
|
201
|
+
{ title: "Average Ticket", value: metrics.data?.[0]?.averageAmount ?? 0, format: "currency" },
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<AppShell
|
|
206
|
+
title="Revenue Explorer"
|
|
207
|
+
subtitle={transactions.kind === "database" ? "Attached DuckDB model" : "Live dataset"}
|
|
208
|
+
theme={theme}
|
|
209
|
+
onToggleTheme={toggleTheme}
|
|
210
|
+
meta={<ResourceCacheBadge rows={transactionRowCount} cache={transactions.cache} onClearAndReload={transactions.refresh} />}
|
|
211
|
+
>
|
|
212
|
+
<TabGroup
|
|
213
|
+
tabs={["Dashboard", "Report", "Explorer", "Branches"]}
|
|
214
|
+
activeTab={activeTab}
|
|
215
|
+
onTabChange={setActiveTab}
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
{appIssues.length > 0 ? (
|
|
219
|
+
<div className="grid gap-3">
|
|
220
|
+
{appIssues.map((issue) => (
|
|
221
|
+
<IssueBanner
|
|
222
|
+
key={issue.key}
|
|
223
|
+
title={issue.title}
|
|
224
|
+
message={issue.message}
|
|
225
|
+
severity={issue.severity}
|
|
226
|
+
/>
|
|
227
|
+
))}
|
|
228
|
+
</div>
|
|
229
|
+
) : null}
|
|
230
|
+
|
|
231
|
+
{activeTab === "Dashboard" ? (
|
|
232
|
+
<>
|
|
233
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
234
|
+
<MultiSelect<BranchOption>
|
|
235
|
+
options={branches.data ?? []}
|
|
236
|
+
selected={selectedBranches}
|
|
237
|
+
onChange={setSelectedBranches}
|
|
238
|
+
labelKey="branchName"
|
|
239
|
+
valueKey="branchId"
|
|
240
|
+
label="Branch"
|
|
241
|
+
placeholder="All Branches"
|
|
242
|
+
searchPlaceholder="Search branches..."
|
|
243
|
+
/>
|
|
244
|
+
<Select
|
|
245
|
+
options={[
|
|
246
|
+
{ value: "Funded", label: "Funded" },
|
|
247
|
+
{ value: "Pending", label: "Pending" },
|
|
248
|
+
{ value: "Review", label: "Review" },
|
|
249
|
+
]}
|
|
250
|
+
value={statusFilter}
|
|
251
|
+
onChange={setStatusFilter}
|
|
252
|
+
label={<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>Status<Tooltip content={<span>Transaction processing status.<br /><b>Funded</b>: completed, <b>Pending</b>: in progress, <b>Review</b>: awaiting approval.</span>}><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5, cursor: "help" }}><circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" /></svg></Tooltip></span>}
|
|
253
|
+
placeholder="All Statuses"
|
|
254
|
+
/>
|
|
255
|
+
{hasFilters ? (
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
className="rounded-lg px-3 py-2 text-xs font-medium"
|
|
259
|
+
style={{
|
|
260
|
+
background: "none",
|
|
261
|
+
border: "1px solid var(--border)",
|
|
262
|
+
color: "var(--text-muted)",
|
|
263
|
+
cursor: "pointer",
|
|
264
|
+
transition: "color 120ms ease, border-color 120ms ease",
|
|
265
|
+
}}
|
|
266
|
+
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text-primary)"; e.currentTarget.style.borderColor = "var(--border-hover)"; }}
|
|
267
|
+
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-muted)"; e.currentTarget.style.borderColor = "var(--border)"; }}
|
|
268
|
+
onClick={() => { setSelectedBranches([]); setStatusFilter(""); }}
|
|
269
|
+
>
|
|
270
|
+
Clear filters
|
|
271
|
+
</button>
|
|
272
|
+
) : null}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
276
|
+
{kpis.map((kpi, i) => (
|
|
277
|
+
<div key={kpi.title} style={{ animation: `fade-up 0.4s ease-out ${i * 0.08}s both` }}>
|
|
278
|
+
<KpiCard
|
|
279
|
+
title={kpi.title}
|
|
280
|
+
value={kpi.value}
|
|
281
|
+
format={kpi.format}
|
|
282
|
+
loading={metrics.loading}
|
|
283
|
+
detail={kpi.detail}
|
|
284
|
+
/>
|
|
285
|
+
</div>
|
|
286
|
+
))}
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<div className="grid gap-6 xl:grid-cols-2">
|
|
290
|
+
<Card title="Revenue by Branch" headerRight={selectedBranches.length > 0 ? <Badge variant="info">{selectedBranches.length} filtered</Badge> : null} noPadding>
|
|
291
|
+
<div className="h-[320px]">
|
|
292
|
+
<PerspectivePanel
|
|
293
|
+
client={perspective.client}
|
|
294
|
+
table={transactions.perspectiveTable}
|
|
295
|
+
resourceKey={transactions.key}
|
|
296
|
+
loading={perspective.loading}
|
|
297
|
+
error={perspective.error}
|
|
298
|
+
theme={theme}
|
|
299
|
+
onSelect={handleBranchDrill}
|
|
300
|
+
config={{
|
|
301
|
+
plugin: "X Bar",
|
|
302
|
+
columns: ["amount"],
|
|
303
|
+
group_by: ["branchName"],
|
|
304
|
+
aggregates: { amount: "sum" },
|
|
305
|
+
sort: [["amount", "desc"]],
|
|
306
|
+
settings: false,
|
|
307
|
+
filter: statusFilter ? [["status", "==", statusFilter]] : [],
|
|
308
|
+
columns_config: {
|
|
309
|
+
amount: { number_format: { style: "currency", currency: "USD", maximumFractionDigits: 0 } },
|
|
310
|
+
},
|
|
311
|
+
}}
|
|
312
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
</Card>
|
|
315
|
+
<Card title="Transactions by Status" headerRight={statusFilter ? <Badge variant="info">{statusFilter}</Badge> : null} noPadding>
|
|
316
|
+
<div className="h-[320px]">
|
|
317
|
+
<PerspectivePanel
|
|
318
|
+
client={perspective.client}
|
|
319
|
+
table={transactions.perspectiveTable}
|
|
320
|
+
resourceKey={transactions.key}
|
|
321
|
+
loading={perspective.loading}
|
|
322
|
+
error={perspective.error}
|
|
323
|
+
theme={theme}
|
|
324
|
+
onSelect={handleStatusDrill}
|
|
325
|
+
config={{
|
|
326
|
+
plugin: "Y Bar",
|
|
327
|
+
columns: ["transactionId"],
|
|
328
|
+
group_by: ["status"],
|
|
329
|
+
aggregates: { transactionId: "count" },
|
|
330
|
+
sort: [["transactionId", "desc"]],
|
|
331
|
+
settings: false,
|
|
332
|
+
filter: selectedBranches.length === 1
|
|
333
|
+
? [["branchName", "==", selectedBranches[0].branchName]]
|
|
334
|
+
: selectedBranches.length > 1
|
|
335
|
+
? [["branchName", "in", selectedBranches.map((b) => b.branchName)]]
|
|
336
|
+
: [],
|
|
337
|
+
}}
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
</Card>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<Card title="Recent Transactions" headerRight={hasFilters ? <Badge variant="info">Filtered</Badge> : null}>
|
|
344
|
+
<DataTable
|
|
345
|
+
columns={[
|
|
346
|
+
{ key: "transactionDate", label: "Date" },
|
|
347
|
+
{ key: "branchName", label: "Branch" },
|
|
348
|
+
{ key: "status", label: "Status" },
|
|
349
|
+
{ key: "amount", label: "Amount" },
|
|
350
|
+
]}
|
|
351
|
+
rows={(recentRows.data as Array<Record<string, unknown>>) ?? []}
|
|
352
|
+
emptyState={recentRows.loading ? "Loading transactions..." : "No transactions match the current filters."}
|
|
353
|
+
/>
|
|
354
|
+
</Card>
|
|
355
|
+
</>
|
|
356
|
+
) : null}
|
|
357
|
+
|
|
358
|
+
{activeTab === "Report" ? (
|
|
359
|
+
<>
|
|
360
|
+
<div className="flex flex-wrap items-end gap-4">
|
|
361
|
+
<FilterPills
|
|
362
|
+
label="Segment"
|
|
363
|
+
options={[
|
|
364
|
+
{ value: "All", label: "All" },
|
|
365
|
+
{ value: "Funded", label: "Funded" },
|
|
366
|
+
{ value: "Pending", label: "Pending" },
|
|
367
|
+
{ value: "Review", label: "Review" },
|
|
368
|
+
]}
|
|
369
|
+
value={reportFilter}
|
|
370
|
+
onChange={setReportFilter}
|
|
371
|
+
/>
|
|
372
|
+
<DateRangeFilter value={reportRange} onChange={setReportRange} />
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<Card>
|
|
376
|
+
<ReportTable
|
|
377
|
+
headerGroups={[
|
|
378
|
+
{
|
|
379
|
+
label: "Category",
|
|
380
|
+
subHeaders: [{ key: "category", label: "Category", align: "left" }],
|
|
381
|
+
color: "var(--bg-elevated)",
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
label: reportRange.label || "Range",
|
|
385
|
+
color: "#92400e",
|
|
386
|
+
subHeaders: [
|
|
387
|
+
{ key: "units", label: "Units" },
|
|
388
|
+
{ key: "volume", label: "Volume" },
|
|
389
|
+
],
|
|
390
|
+
},
|
|
391
|
+
]}
|
|
392
|
+
rows={(() => {
|
|
393
|
+
const data = reportAgg.data ?? [];
|
|
394
|
+
const fmtMoney = (v: number) => `$${Math.round(v).toLocaleString()}`;
|
|
395
|
+
const branchRows = data.map((r) => ({
|
|
396
|
+
type: "data" as const,
|
|
397
|
+
indent: true,
|
|
398
|
+
cells: {
|
|
399
|
+
category: r.branchName,
|
|
400
|
+
units: String(r.units),
|
|
401
|
+
volume: fmtMoney(r.volume),
|
|
402
|
+
},
|
|
403
|
+
}));
|
|
404
|
+
const totalUnits = data.reduce((s, r) => s + Number(r.units || 0), 0);
|
|
405
|
+
const totalVolume = data.reduce((s, r) => s + Number(r.volume || 0), 0);
|
|
406
|
+
if (branchRows.length === 0) {
|
|
407
|
+
return [
|
|
408
|
+
{ type: "section" as const, cells: { category: "Branches" } },
|
|
409
|
+
{
|
|
410
|
+
type: "data" as const,
|
|
411
|
+
indent: true,
|
|
412
|
+
cells: {
|
|
413
|
+
category: reportAgg.loading ? "Loading..." : "No transactions in range",
|
|
414
|
+
units: "—",
|
|
415
|
+
volume: "—",
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
];
|
|
419
|
+
}
|
|
420
|
+
return [
|
|
421
|
+
{ type: "section" as const, cells: { category: "Branches" } },
|
|
422
|
+
...branchRows,
|
|
423
|
+
{
|
|
424
|
+
type: "total" as const,
|
|
425
|
+
cells: {
|
|
426
|
+
category: "GRAND TOTAL",
|
|
427
|
+
units: String(totalUnits),
|
|
428
|
+
volume: fmtMoney(totalVolume),
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
];
|
|
432
|
+
})()}
|
|
433
|
+
/>
|
|
434
|
+
</Card>
|
|
435
|
+
|
|
436
|
+
<Card title="Monthly Volume vs Target" noPadding>
|
|
437
|
+
<div className="p-4">
|
|
438
|
+
<EChart
|
|
439
|
+
theme={theme}
|
|
440
|
+
height={340}
|
|
441
|
+
option={{
|
|
442
|
+
tooltip: { trigger: "axis" },
|
|
443
|
+
legend: { data: ["Actual", "Target"], top: 0, textStyle: { fontSize: 11 } },
|
|
444
|
+
grid: { left: 60, right: 20, bottom: 30, top: 40 },
|
|
445
|
+
xAxis: {
|
|
446
|
+
type: "category",
|
|
447
|
+
data: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
|
|
448
|
+
},
|
|
449
|
+
yAxis: {
|
|
450
|
+
type: "value",
|
|
451
|
+
axisLabel: { formatter: (v: number) => v >= 1000 ? `$${v / 1000}K` : `$${v}` },
|
|
452
|
+
},
|
|
453
|
+
series: [
|
|
454
|
+
{
|
|
455
|
+
name: "Actual",
|
|
456
|
+
type: "bar",
|
|
457
|
+
data: [
|
|
458
|
+
{ value: 42000, itemStyle: { color: "#22c55e" } },
|
|
459
|
+
{ value: 38000, itemStyle: { color: "#22c55e" } },
|
|
460
|
+
{ value: 28000, itemStyle: { color: "#f87171" } },
|
|
461
|
+
{ value: 0, itemStyle: { color: "#71717a" } },
|
|
462
|
+
{ value: 0, itemStyle: { color: "#71717a" } },
|
|
463
|
+
{ value: 0, itemStyle: { color: "#71717a" } },
|
|
464
|
+
],
|
|
465
|
+
barMaxWidth: 40,
|
|
466
|
+
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
name: "Target",
|
|
470
|
+
type: "line",
|
|
471
|
+
data: [35000, 35000, 40000, 40000, 45000, 45000],
|
|
472
|
+
lineStyle: { type: "dashed", width: 2, color: "#eab308" },
|
|
473
|
+
itemStyle: { color: "#eab308" },
|
|
474
|
+
symbol: "circle",
|
|
475
|
+
symbolSize: 6,
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
}}
|
|
479
|
+
/>
|
|
480
|
+
</div>
|
|
481
|
+
</Card>
|
|
482
|
+
</>
|
|
483
|
+
) : null}
|
|
484
|
+
|
|
485
|
+
{activeTab === "Explorer" ? (
|
|
486
|
+
<Card title="Perspective Explorer" noPadding>
|
|
487
|
+
<div className="h-[600px]">
|
|
488
|
+
<PerspectivePanel
|
|
489
|
+
client={perspective.client}
|
|
490
|
+
table={transactions.perspectiveTable}
|
|
491
|
+
resourceKey={transactions.key}
|
|
492
|
+
loading={perspective.loading}
|
|
493
|
+
error={perspective.error}
|
|
494
|
+
theme={theme}
|
|
495
|
+
config={{
|
|
496
|
+
plugin: "Datagrid",
|
|
497
|
+
columns: ["transactionDate", "branchName", "status", "amount"],
|
|
498
|
+
sort: [["transactionDate", "desc"]],
|
|
499
|
+
settings: false,
|
|
500
|
+
}}
|
|
501
|
+
/>
|
|
502
|
+
</div>
|
|
503
|
+
</Card>
|
|
504
|
+
) : null}
|
|
505
|
+
|
|
506
|
+
{activeTab === "Branches" ? (
|
|
507
|
+
<Card title="Branch Directory">
|
|
508
|
+
<TextInput
|
|
509
|
+
value={search}
|
|
510
|
+
onChange={setSearch}
|
|
511
|
+
placeholder="Search branches..."
|
|
512
|
+
icon={searchIcon}
|
|
513
|
+
className="mb-4"
|
|
514
|
+
/>
|
|
515
|
+
<DataTable
|
|
516
|
+
columns={[
|
|
517
|
+
{ key: "branchId", label: "Branch ID" },
|
|
518
|
+
{ key: "branchName", label: "Branch Name" },
|
|
519
|
+
]}
|
|
520
|
+
rows={filteredBranches}
|
|
521
|
+
emptyState={branches.loading ? "Loading branches..." : "No branches matched."}
|
|
522
|
+
/>
|
|
523
|
+
</Card>
|
|
524
|
+
) : null}
|
|
525
|
+
</AppShell>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
|
|
4
|
+
import App from "./App";
|
|
5
|
+
|
|
6
|
+
const rootElement = document.getElementById("root");
|
|
7
|
+
|
|
8
|
+
if (!rootElement) {
|
|
9
|
+
throw new Error("Missing #root mount element");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
createRoot(rootElement).render(<App />);
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@definite-app/data-apps",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Build source-authored React data apps that compile to a single HTML file with DuckDB WASM and Perspective.js.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://github.com/definite-app/definite-data-apps#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/definite-app/definite-data-apps.git"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"definite-build": "./build.mjs",
|
|
14
|
+
"definite-preview": "./preview.mjs"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"build.mjs",
|
|
18
|
+
"preview.mjs",
|
|
19
|
+
"runtime/",
|
|
20
|
+
"templates/",
|
|
21
|
+
"examples/_refined_demo/",
|
|
22
|
+
"examples/loan-portfolio/",
|
|
23
|
+
"examples/revenue-explorer/",
|
|
24
|
+
"starter",
|
|
25
|
+
"scripts/",
|
|
26
|
+
"CLAUDE.md",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "node build.mjs",
|
|
34
|
+
"preview": "node preview.mjs",
|
|
35
|
+
"clean": "find examples templates -type d -name dist -exec rm -rf {} + 2>/dev/null || true",
|
|
36
|
+
"prepack": "npm run clean"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"data-apps",
|
|
40
|
+
"duckdb",
|
|
41
|
+
"duckdb-wasm",
|
|
42
|
+
"perspective",
|
|
43
|
+
"react",
|
|
44
|
+
"definite"
|
|
45
|
+
],
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"esbuild": "0.25.3",
|
|
48
|
+
"react": "19.1.0",
|
|
49
|
+
"react-dom": "19.1.0",
|
|
50
|
+
"source-map-js": "^1.2.1"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"playwright": "^1.48.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/preview.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const buildScript = path.join(repoRoot, "build.mjs");
|
|
8
|
+
|
|
9
|
+
const appName = process.argv[2];
|
|
10
|
+
if (!appName) {
|
|
11
|
+
console.error("Usage: node preview.mjs <app-name>");
|
|
12
|
+
console.error(" e.g. node preview.mjs revenue-explorer");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Look for the app in examples/ first, then current directory
|
|
17
|
+
const appDir = path.resolve(
|
|
18
|
+
path.join(repoRoot, "examples", appName, "app.json")
|
|
19
|
+
? path.join(repoRoot, "examples", appName)
|
|
20
|
+
: appName
|
|
21
|
+
);
|
|
22
|
+
const previewDataPath = path.join(appDir, "preview-data.json");
|
|
23
|
+
|
|
24
|
+
const child = spawn(
|
|
25
|
+
process.execPath,
|
|
26
|
+
[buildScript, appDir, "--preview-data", previewDataPath],
|
|
27
|
+
{ stdio: "inherit" },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
child.on("exit", (code) => {
|
|
31
|
+
if (code !== 0) {
|
|
32
|
+
process.exit(code ?? 1);
|
|
33
|
+
}
|
|
34
|
+
console.log(`Preview built at ${path.join(appDir, "dist", "index.html")}`);
|
|
35
|
+
});
|