@schandlergarcia/sf-web-components 1.9.38 → 1.9.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/scripts/postinstall.mjs +36 -17
- package/src/components/library/cards/ActionList.jsx +38 -0
- package/src/components/library/cards/ActivityCard.jsx +56 -0
- package/src/components/library/cards/BaseCard.jsx +109 -0
- package/src/components/library/cards/CalloutCard.jsx +37 -0
- package/src/components/library/cards/ChartCard.jsx +105 -0
- package/src/components/library/cards/FeedPanel.jsx +39 -0
- package/src/components/library/cards/ListCard.jsx +193 -0
- package/src/components/library/cards/MetricCard.jsx +109 -0
- package/src/components/library/cards/MetricsStrip.jsx +78 -0
- package/src/components/library/cards/SectionCard.jsx +83 -0
- package/src/components/library/cards/SemanticMetricCard.jsx +52 -0
- package/src/components/library/cards/SemanticMetricCardWithLoading.jsx +23 -0
- package/src/components/library/cards/SemanticTableCard.jsx +48 -0
- package/src/components/library/cards/SemanticTableCardWithLoading.jsx +22 -0
- package/src/components/library/cards/StatusCard.jsx +220 -0
- package/src/components/library/cards/TableCard.jsx +337 -0
- package/src/components/library/cards/WidgetCard.jsx +90 -0
- package/src/components/library/charts/D3Chart.jsx +109 -0
- package/src/components/library/charts/D3ChartTemplates.jsx +126 -0
- package/src/components/library/charts/GeoMap.jsx +293 -0
- package/src/components/library/chat/ChatBar.jsx +256 -0
- package/src/components/library/chat/ChatInput.jsx +89 -0
- package/src/components/library/chat/ChatMessage.jsx +178 -0
- package/src/components/library/chat/ChatMessageList.jsx +73 -0
- package/src/components/library/chat/ChatPanel.jsx +97 -0
- package/src/components/library/chat/ChatSuggestions.jsx +28 -0
- package/src/components/library/chat/ChatToolCall.jsx +100 -0
- package/src/components/library/chat/ChatTypingIndicator.jsx +23 -0
- package/src/components/library/chat/ChatWelcome.jsx +43 -0
- package/src/components/library/chat/index.jsx +10 -0
- package/src/components/library/chat/useChatState.jsx +130 -0
- package/src/components/library/data/DataModeProvider.jsx +67 -0
- package/src/components/library/data/DataModeToggle.jsx +36 -0
- package/src/components/library/data/chartDataProvider.jsx +61 -0
- package/src/components/library/data/filterUtils.jsx +141 -0
- package/src/components/library/data/useDataSource.jsx +33 -0
- package/src/components/library/data/usePageFilters.jsx +99 -0
- package/src/components/library/filters/FilterBar.jsx +95 -0
- package/src/components/library/filters/SearchFilter.jsx +36 -0
- package/src/components/library/filters/SelectFilter.jsx +55 -0
- package/src/components/library/filters/ToggleFilter.jsx +52 -0
- package/src/components/library/filters/index.jsx +4 -0
- package/src/components/library/forms/FormField.jsx +291 -0
- package/src/components/library/forms/FormModal.jsx +201 -0
- package/src/components/library/forms/FormRenderer.jsx +46 -0
- package/src/components/library/forms/FormSection.jsx +69 -0
- package/src/components/library/forms/index.jsx +5 -0
- package/src/components/library/forms/useFormState.jsx +165 -0
- package/src/components/library/heroui/Accordion.jsx +26 -0
- package/src/components/library/heroui/Alert.jsx +8 -0
- package/src/components/library/heroui/Badge.jsx +8 -0
- package/src/components/library/heroui/Breadcrumbs.jsx +22 -0
- package/src/components/library/heroui/Button.jsx +58 -0
- package/src/components/library/heroui/Card.jsx +8 -0
- package/src/components/library/heroui/Collapsible.jsx +42 -0
- package/src/components/library/heroui/DatePicker.jsx +34 -0
- package/src/components/library/heroui/Dialog.jsx +37 -0
- package/src/components/library/heroui/Drawer.jsx +32 -0
- package/src/components/library/heroui/Dropdown.jsx +28 -0
- package/src/components/library/heroui/Field.jsx +51 -0
- package/src/components/library/heroui/Input.jsx +6 -0
- package/src/components/library/heroui/Kbd.jsx +8 -0
- package/src/components/library/heroui/Meter.jsx +8 -0
- package/src/components/library/heroui/Modal.jsx +32 -0
- package/src/components/library/heroui/Pagination.jsx +8 -0
- package/src/components/library/heroui/Popover.jsx +64 -0
- package/src/components/library/heroui/ProgressBar.jsx +8 -0
- package/src/components/library/heroui/ProgressCircle.jsx +8 -0
- package/src/components/library/heroui/ScrollShadow.jsx +8 -0
- package/src/components/library/heroui/Select.jsx +37 -0
- package/src/components/library/heroui/Separator.jsx +8 -0
- package/src/components/library/heroui/Skeleton.jsx +8 -0
- package/src/components/library/heroui/Tabs.jsx +26 -0
- package/src/components/library/heroui/Toast.jsx +25 -0
- package/src/components/library/heroui/Toggle.jsx +14 -0
- package/src/components/library/heroui/Tooltip.jsx +21 -0
- package/src/components/library/index.jsx +146 -0
- package/src/components/library/layout/PageContainer.jsx +11 -0
- package/src/components/library/skeletons/CardSkeleton.jsx +30 -0
- package/src/components/library/theme/AppThemeProvider.jsx +67 -0
- package/src/components/library/theme/tokens.jsx +72 -0
- package/src/components/library/ui/Alert.jsx +80 -0
- package/src/components/library/ui/Avatar.jsx +44 -0
- package/src/components/library/ui/BreadcrumbExtras.tsx +120 -0
- package/src/components/library/ui/Button.jsx +61 -0
- package/src/components/library/ui/Card.jsx +117 -0
- package/src/components/library/ui/Checkbox.jsx +17 -0
- package/src/components/library/ui/Chip.jsx +38 -0
- package/src/components/library/ui/Collapsible.tsx +31 -0
- package/src/components/library/ui/Container.jsx +56 -0
- package/src/components/library/ui/DatePicker.tsx +34 -0
- package/src/components/library/ui/Dialog.tsx +141 -0
- package/src/components/library/ui/EmptyState.jsx +46 -0
- package/src/components/library/ui/Field.tsx +82 -0
- package/src/components/library/ui/FieldGroup.jsx +17 -0
- package/src/components/library/ui/Input.jsx +21 -0
- package/src/components/library/ui/Label.jsx +22 -0
- package/src/components/library/ui/PaginationExtras.tsx +142 -0
- package/src/components/library/ui/Popover.tsx +39 -0
- package/src/components/library/ui/Select.tsx +113 -0
- package/src/components/library/ui/Spinner.d.ts +10 -0
- package/src/components/library/ui/Spinner.jsx +64 -0
- package/src/components/library/ui/Text.jsx +46 -0
- package/src/components/library/ui/Toggle.jsx +42 -0
- package/src/components/workspace/ComponentRegistry.jsx +297 -0
- package/src/lib/index.ts +1 -0
- package/src/lib/utils.ts +6 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import BaseCard from "./BaseCard";
|
|
3
|
+
import UIInput from "../ui/Input";
|
|
4
|
+
import UIButton from "../ui/Button";
|
|
5
|
+
import UIText from "../ui/Text";
|
|
6
|
+
|
|
7
|
+
function defaultTypeFormat(type, value) {
|
|
8
|
+
if (value == null) return "";
|
|
9
|
+
if (!type) return String(value);
|
|
10
|
+
|
|
11
|
+
if (type === "currency") {
|
|
12
|
+
const n = Number(value);
|
|
13
|
+
if (Number.isFinite(n)) return n.toLocaleString(undefined, { style: "currency", currency: "USD" });
|
|
14
|
+
}
|
|
15
|
+
if (type === "percentage") {
|
|
16
|
+
const n = Number(value);
|
|
17
|
+
if (Number.isFinite(n)) return `${n}%`;
|
|
18
|
+
}
|
|
19
|
+
if (type === "number") {
|
|
20
|
+
const n = Number(value);
|
|
21
|
+
if (Number.isFinite(n)) return n.toLocaleString();
|
|
22
|
+
}
|
|
23
|
+
return String(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stableSort(data, compare) {
|
|
27
|
+
return data
|
|
28
|
+
.map((item, idx) => ({ item, idx }))
|
|
29
|
+
.sort((a, b) => {
|
|
30
|
+
const c = compare(a.item, b.item);
|
|
31
|
+
return c !== 0 ? c : a.idx - b.idx;
|
|
32
|
+
})
|
|
33
|
+
.map((x) => x.item);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function TableCard({
|
|
37
|
+
data = [],
|
|
38
|
+
columns = [],
|
|
39
|
+
title,
|
|
40
|
+
subtitle,
|
|
41
|
+
searchable = false,
|
|
42
|
+
sortable = false,
|
|
43
|
+
paginated = false,
|
|
44
|
+
selectable = false,
|
|
45
|
+
pageSize = 10,
|
|
46
|
+
actions,
|
|
47
|
+
rowActions,
|
|
48
|
+
onRowSelect,
|
|
49
|
+
onSort,
|
|
50
|
+
onSearch,
|
|
51
|
+
loading = false,
|
|
52
|
+
error,
|
|
53
|
+
emptyMessage = "No results.",
|
|
54
|
+
simulateInitialLoad = false,
|
|
55
|
+
minInitialDelayMs = 350,
|
|
56
|
+
maxInitialDelayMs = 900,
|
|
57
|
+
...cardProps
|
|
58
|
+
}) {
|
|
59
|
+
const [query, setQuery] = React.useState("");
|
|
60
|
+
const [sortKey, setSortKey] = React.useState(null);
|
|
61
|
+
const [sortDir, setSortDir] = React.useState("asc");
|
|
62
|
+
const [page, setPage] = React.useState(1);
|
|
63
|
+
const [selectedId, setSelectedId] = React.useState(null);
|
|
64
|
+
const [simLoading, setSimLoading] = React.useState(simulateInitialLoad);
|
|
65
|
+
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
if (!simulateInitialLoad) return;
|
|
68
|
+
const delay =
|
|
69
|
+
Math.floor(Math.random() * (maxInitialDelayMs - minInitialDelayMs + 1)) + minInitialDelayMs;
|
|
70
|
+
const t = setTimeout(() => setSimLoading(false), delay);
|
|
71
|
+
return () => clearTimeout(t);
|
|
72
|
+
}, [simulateInitialLoad, minInitialDelayMs, maxInitialDelayMs]);
|
|
73
|
+
|
|
74
|
+
const effectiveLoading = loading || simLoading;
|
|
75
|
+
|
|
76
|
+
const filtered = React.useMemo(() => {
|
|
77
|
+
if (!searchable || !query.trim()) return data;
|
|
78
|
+
const q = query.trim().toLowerCase();
|
|
79
|
+
return data.filter((row) =>
|
|
80
|
+
columns.some((col) => {
|
|
81
|
+
const raw = row?.[col.key];
|
|
82
|
+
if (raw == null) return false;
|
|
83
|
+
return String(raw).toLowerCase().includes(q);
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
}, [data, columns, query, searchable]);
|
|
87
|
+
|
|
88
|
+
const sorted = React.useMemo(() => {
|
|
89
|
+
if (!sortable || !sortKey) return filtered;
|
|
90
|
+
const col = columns.find((c) => c.key === sortKey);
|
|
91
|
+
if (!col) return filtered;
|
|
92
|
+
const dir = sortDir === "desc" ? -1 : 1;
|
|
93
|
+
return stableSort(filtered, (a, b) => {
|
|
94
|
+
const av = a?.[sortKey];
|
|
95
|
+
const bv = b?.[sortKey];
|
|
96
|
+
if (av == null && bv == null) return 0;
|
|
97
|
+
if (av == null) return -1 * dir;
|
|
98
|
+
if (bv == null) return 1 * dir;
|
|
99
|
+
if (typeof av === "number" && typeof bv === "number") return (av - bv) * dir;
|
|
100
|
+
return String(av).localeCompare(String(bv)) * dir;
|
|
101
|
+
});
|
|
102
|
+
}, [filtered, sortable, sortKey, sortDir, columns]);
|
|
103
|
+
|
|
104
|
+
const total = sorted.length;
|
|
105
|
+
const totalPages = paginated ? Math.max(1, Math.ceil(total / pageSize)) : 1;
|
|
106
|
+
const clampedPage = Math.min(page, totalPages);
|
|
107
|
+
|
|
108
|
+
const pageData = React.useMemo(() => {
|
|
109
|
+
if (!paginated) return sorted;
|
|
110
|
+
const start = (clampedPage - 1) * pageSize;
|
|
111
|
+
return sorted.slice(start, start + pageSize);
|
|
112
|
+
}, [sorted, paginated, clampedPage, pageSize]);
|
|
113
|
+
|
|
114
|
+
React.useEffect(() => {
|
|
115
|
+
if (page !== clampedPage) setPage(clampedPage);
|
|
116
|
+
}, [page, clampedPage]);
|
|
117
|
+
|
|
118
|
+
const header = (
|
|
119
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
120
|
+
<div className="min-w-0">
|
|
121
|
+
{title ? (
|
|
122
|
+
<UIText as="div" size="sm" weight="medium">
|
|
123
|
+
{title}
|
|
124
|
+
</UIText>
|
|
125
|
+
) : null}
|
|
126
|
+
{subtitle ? (
|
|
127
|
+
<UIText as="div" size="xs" muted className="mt-1">
|
|
128
|
+
{subtitle}
|
|
129
|
+
</UIText>
|
|
130
|
+
) : null}
|
|
131
|
+
</div>
|
|
132
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
|
|
133
|
+
{searchable ? (
|
|
134
|
+
<div className="w-full sm:w-64">
|
|
135
|
+
<UIInput
|
|
136
|
+
value={query}
|
|
137
|
+
onChange={(e) => {
|
|
138
|
+
setQuery(e.target.value);
|
|
139
|
+
setPage(1);
|
|
140
|
+
onSearch?.(e.target.value);
|
|
141
|
+
}}
|
|
142
|
+
placeholder="Search…"
|
|
143
|
+
aria-label="Search table"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
) : null}
|
|
147
|
+
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (error) {
|
|
153
|
+
return (
|
|
154
|
+
<BaseCard
|
|
155
|
+
header={header}
|
|
156
|
+
body={
|
|
157
|
+
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900 dark:border-rose-900/40 dark:bg-rose-950/30 dark:text-rose-100">
|
|
158
|
+
{String(error)}
|
|
159
|
+
</div>
|
|
160
|
+
}
|
|
161
|
+
{...cardProps}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const canSort = (col) => sortable && (col.sortable ?? true);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<BaseCard
|
|
170
|
+
variant="table"
|
|
171
|
+
header={header}
|
|
172
|
+
body={
|
|
173
|
+
<div className="mt-4 overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
|
|
174
|
+
<div className="w-full overflow-x-auto">
|
|
175
|
+
<table className="min-w-full border-separate border-spacing-0">
|
|
176
|
+
<thead className="bg-slate-50 dark:bg-slate-950/30">
|
|
177
|
+
<tr>
|
|
178
|
+
{columns.map((col) => {
|
|
179
|
+
const active = sortKey === col.key;
|
|
180
|
+
return (
|
|
181
|
+
<th
|
|
182
|
+
key={col.key}
|
|
183
|
+
scope="col"
|
|
184
|
+
className={[
|
|
185
|
+
"whitespace-nowrap border-b border-slate-200 px-4 py-3 text-left text-xs font-semibold text-slate-600 dark:border-slate-800 dark:text-slate-300",
|
|
186
|
+
col.className ?? ""
|
|
187
|
+
]
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
.join(" ")}
|
|
190
|
+
>
|
|
191
|
+
{canSort(col) ? (
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
className="inline-flex items-center gap-2 hover:text-slate-900 dark:hover:text-slate-50"
|
|
195
|
+
onClick={() => {
|
|
196
|
+
const nextDir = active && sortDir === "asc" ? "desc" : "asc";
|
|
197
|
+
setSortKey(col.key);
|
|
198
|
+
setSortDir(nextDir);
|
|
199
|
+
onSort?.({ key: col.key, direction: nextDir });
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
<span>{col.label}</span>
|
|
203
|
+
<span className="text-[10px] opacity-70">
|
|
204
|
+
{active ? (sortDir === "asc" ? "▲" : "▼") : "↕"}
|
|
205
|
+
</span>
|
|
206
|
+
</button>
|
|
207
|
+
) : (
|
|
208
|
+
col.label
|
|
209
|
+
)}
|
|
210
|
+
</th>
|
|
211
|
+
);
|
|
212
|
+
})}
|
|
213
|
+
{rowActions ? (
|
|
214
|
+
<th className="border-b border-slate-200 px-4 py-3 text-right text-xs font-semibold text-slate-600 dark:border-slate-800 dark:text-slate-300">
|
|
215
|
+
Actions
|
|
216
|
+
</th>
|
|
217
|
+
) : null}
|
|
218
|
+
</tr>
|
|
219
|
+
</thead>
|
|
220
|
+
|
|
221
|
+
<tbody className="bg-white dark:bg-slate-900">
|
|
222
|
+
{effectiveLoading ? (
|
|
223
|
+
Array.from({ length: Math.min(pageSize, 6) }).map((_, idx) => (
|
|
224
|
+
<tr key={idx}>
|
|
225
|
+
{columns.map((col) => (
|
|
226
|
+
<td
|
|
227
|
+
key={col.key}
|
|
228
|
+
className="border-b border-slate-200 px-4 py-3 dark:border-slate-800"
|
|
229
|
+
>
|
|
230
|
+
<div className="h-4 w-3/4 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
|
|
231
|
+
</td>
|
|
232
|
+
))}
|
|
233
|
+
{rowActions ? (
|
|
234
|
+
<td className="border-b border-slate-200 px-4 py-3 dark:border-slate-800">
|
|
235
|
+
<div className="h-4 w-10 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
|
|
236
|
+
</td>
|
|
237
|
+
) : null}
|
|
238
|
+
</tr>
|
|
239
|
+
))
|
|
240
|
+
) : pageData.length === 0 ? (
|
|
241
|
+
<tr>
|
|
242
|
+
<td
|
|
243
|
+
colSpan={columns.length + (rowActions ? 1 : 0)}
|
|
244
|
+
className="px-4 py-10 text-center text-sm text-slate-600 dark:text-slate-300"
|
|
245
|
+
>
|
|
246
|
+
{emptyMessage}
|
|
247
|
+
</td>
|
|
248
|
+
</tr>
|
|
249
|
+
) : (
|
|
250
|
+
pageData.map((row, idx) => {
|
|
251
|
+
const rowId = row?.id ?? idx;
|
|
252
|
+
const selected = selectable && selectedId === rowId;
|
|
253
|
+
return (
|
|
254
|
+
<tr
|
|
255
|
+
key={rowId}
|
|
256
|
+
className={[
|
|
257
|
+
"group",
|
|
258
|
+
selectable ? "cursor-pointer" : "",
|
|
259
|
+
selected ? "bg-brand-50 dark:bg-brand-950/30" : ""
|
|
260
|
+
]
|
|
261
|
+
.filter(Boolean)
|
|
262
|
+
.join(" ")}
|
|
263
|
+
onClick={() => {
|
|
264
|
+
if (!selectable) return;
|
|
265
|
+
setSelectedId(rowId);
|
|
266
|
+
onRowSelect?.(row);
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
{columns.map((col) => {
|
|
270
|
+
const raw = row?.[col.key];
|
|
271
|
+
const content = col.render ? col.render(raw, row) : defaultTypeFormat(col.type, raw);
|
|
272
|
+
return (
|
|
273
|
+
<td
|
|
274
|
+
key={col.key}
|
|
275
|
+
className={[
|
|
276
|
+
"border-b border-slate-200 px-4 py-3 text-sm text-slate-700 dark:border-slate-800 dark:text-slate-200",
|
|
277
|
+
col.mono ? "font-mono text-[13px]" : "",
|
|
278
|
+
col.className ?? ""
|
|
279
|
+
]
|
|
280
|
+
.filter(Boolean)
|
|
281
|
+
.join(" ")}
|
|
282
|
+
>
|
|
283
|
+
{content}
|
|
284
|
+
</td>
|
|
285
|
+
);
|
|
286
|
+
})}
|
|
287
|
+
{rowActions ? (
|
|
288
|
+
<td className="border-b border-slate-200 px-4 py-3 text-right text-sm dark:border-slate-800">
|
|
289
|
+
<div className="inline-flex items-center justify-end gap-2 opacity-100 sm:opacity-0 sm:group-hover:opacity-100">
|
|
290
|
+
{rowActions(row)}
|
|
291
|
+
</div>
|
|
292
|
+
</td>
|
|
293
|
+
) : null}
|
|
294
|
+
</tr>
|
|
295
|
+
);
|
|
296
|
+
})
|
|
297
|
+
)}
|
|
298
|
+
</tbody>
|
|
299
|
+
</table>
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
{paginated && !effectiveLoading ? (
|
|
303
|
+
<div className="flex flex-col gap-2 border-t border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/30 sm:flex-row sm:items-center sm:justify-between">
|
|
304
|
+
<div className="text-xs text-slate-600 dark:text-slate-300">
|
|
305
|
+
{total === 0 ? "0 results" : `Showing ${(clampedPage - 1) * pageSize + 1}-${Math.min(clampedPage * pageSize, total)} of ${total}`}
|
|
306
|
+
</div>
|
|
307
|
+
<div className="flex items-center gap-2">
|
|
308
|
+
<UIButton
|
|
309
|
+
variant="outline"
|
|
310
|
+
size="sm"
|
|
311
|
+
disabled={clampedPage <= 1}
|
|
312
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
313
|
+
>
|
|
314
|
+
Prev
|
|
315
|
+
</UIButton>
|
|
316
|
+
<div className="text-xs text-slate-600 dark:text-slate-300">
|
|
317
|
+
Page {clampedPage} / {totalPages}
|
|
318
|
+
</div>
|
|
319
|
+
<UIButton
|
|
320
|
+
variant="outline"
|
|
321
|
+
size="sm"
|
|
322
|
+
disabled={clampedPage >= totalPages}
|
|
323
|
+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
324
|
+
>
|
|
325
|
+
Next
|
|
326
|
+
</UIButton>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
) : null}
|
|
330
|
+
</div>
|
|
331
|
+
}
|
|
332
|
+
{...cardProps}
|
|
333
|
+
/>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import BaseCard from "./BaseCard";
|
|
3
|
+
import UIText from "../ui/Text";
|
|
4
|
+
|
|
5
|
+
function Section({ title, actions, content, divided }) {
|
|
6
|
+
return (
|
|
7
|
+
<div className={divided ? "border-t border-slate-200 pt-4 dark:border-slate-800" : ""}>
|
|
8
|
+
{(title || actions) ? (
|
|
9
|
+
<div className="mb-3 flex items-start justify-between gap-3">
|
|
10
|
+
{title ? (
|
|
11
|
+
<UIText as="div" size="sm" weight="medium">
|
|
12
|
+
{title}
|
|
13
|
+
</UIText>
|
|
14
|
+
) : (
|
|
15
|
+
<div />
|
|
16
|
+
)}
|
|
17
|
+
{actions ? <div className="shrink-0">{actions}</div> : null}
|
|
18
|
+
</div>
|
|
19
|
+
) : null}
|
|
20
|
+
<div>{content}</div>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function WidgetCard({
|
|
26
|
+
header,
|
|
27
|
+
sections = [],
|
|
28
|
+
footer,
|
|
29
|
+
divided = false,
|
|
30
|
+
collapsible = false,
|
|
31
|
+
defaultExpanded = true,
|
|
32
|
+
loading = false,
|
|
33
|
+
emptyMessage = "No sections.",
|
|
34
|
+
...cardProps
|
|
35
|
+
}) {
|
|
36
|
+
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
|
37
|
+
|
|
38
|
+
const hdr = (
|
|
39
|
+
<div className="flex items-start justify-between gap-3">
|
|
40
|
+
<div className="min-w-0">{header}</div>
|
|
41
|
+
{collapsible ? (
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
className="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs font-semibold text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
45
|
+
onClick={() => setExpanded((e) => !e)}
|
|
46
|
+
aria-expanded={expanded}
|
|
47
|
+
aria-label="Toggle widget"
|
|
48
|
+
>
|
|
49
|
+
{expanded ? "Collapse" : "Expand"}
|
|
50
|
+
</button>
|
|
51
|
+
) : null}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const body = (
|
|
56
|
+
<div className="mt-4 space-y-4">
|
|
57
|
+
{loading ? (
|
|
58
|
+
<div className="space-y-3">
|
|
59
|
+
<div className="h-4 w-1/3 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
|
|
60
|
+
<div className="h-4 w-2/3 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
|
|
61
|
+
<div className="h-4 w-1/2 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
|
|
62
|
+
</div>
|
|
63
|
+
) : expanded ? (
|
|
64
|
+
sections.length === 0 ? (
|
|
65
|
+
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-6 text-center text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-950/30 dark:text-slate-300">
|
|
66
|
+
{emptyMessage}
|
|
67
|
+
</div>
|
|
68
|
+
) : (
|
|
69
|
+
sections.map((s, idx) => (
|
|
70
|
+
<Section
|
|
71
|
+
key={s?.id ?? idx}
|
|
72
|
+
title={s?.title}
|
|
73
|
+
actions={s?.actions}
|
|
74
|
+
content={s?.content}
|
|
75
|
+
divided={divided && idx !== 0}
|
|
76
|
+
/>
|
|
77
|
+
))
|
|
78
|
+
)
|
|
79
|
+
) : (
|
|
80
|
+
<div className="text-sm text-slate-600 dark:text-slate-300">Collapsed.</div>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{footer ? <div className={divided ? "border-t border-slate-200 pt-4 dark:border-slate-800" : ""}>{footer}</div> : null}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return <BaseCard variant="widget" header={hdr} body={body} {...cardProps} />;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal D3 chart host:
|
|
5
|
+
* - Owns the <svg> element
|
|
6
|
+
* - Computes responsive dimensions from container size
|
|
7
|
+
* - Calls renderChart(svgEl, data, dims, options)
|
|
8
|
+
*/
|
|
9
|
+
export default function D3Chart({
|
|
10
|
+
data,
|
|
11
|
+
renderChart,
|
|
12
|
+
options = {},
|
|
13
|
+
width,
|
|
14
|
+
height = 280,
|
|
15
|
+
responsive = false,
|
|
16
|
+
aspectRatio,
|
|
17
|
+
className = "",
|
|
18
|
+
style,
|
|
19
|
+
containerStyle,
|
|
20
|
+
svgStyle,
|
|
21
|
+
loading = false,
|
|
22
|
+
error,
|
|
23
|
+
ariaLabel = "Chart"
|
|
24
|
+
}) {
|
|
25
|
+
const containerRef = React.useRef(null);
|
|
26
|
+
const svgRef = React.useRef(null);
|
|
27
|
+
const [containerWidth, setContainerWidth] = React.useState(null);
|
|
28
|
+
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
if (!responsive) return;
|
|
31
|
+
const el = containerRef.current;
|
|
32
|
+
if (!el) return;
|
|
33
|
+
|
|
34
|
+
const obs = new ResizeObserver((entries) => {
|
|
35
|
+
const w = entries?.[0]?.contentRect?.width;
|
|
36
|
+
if (typeof w === "number" && Number.isFinite(w)) setContainerWidth(w);
|
|
37
|
+
});
|
|
38
|
+
obs.observe(el);
|
|
39
|
+
return () => obs.disconnect();
|
|
40
|
+
}, [responsive]);
|
|
41
|
+
|
|
42
|
+
const computedWidth = responsive ? containerWidth : width;
|
|
43
|
+
const computedHeight = React.useMemo(() => {
|
|
44
|
+
if (!responsive) return height;
|
|
45
|
+
if (!containerWidth) return height;
|
|
46
|
+
if (aspectRatio && Number.isFinite(aspectRatio) && aspectRatio > 0) return containerWidth / aspectRatio;
|
|
47
|
+
return height;
|
|
48
|
+
}, [responsive, containerWidth, height, aspectRatio]);
|
|
49
|
+
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
if (loading || error) return;
|
|
52
|
+
if (!renderChart) return;
|
|
53
|
+
const svgEl = svgRef.current;
|
|
54
|
+
if (!svgEl) return;
|
|
55
|
+
|
|
56
|
+
const dims = {
|
|
57
|
+
width: computedWidth ?? 0,
|
|
58
|
+
height: computedHeight ?? 0
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Avoid calling renderChart before we have a measurable width in responsive mode
|
|
62
|
+
if (responsive && (!dims.width || dims.width < 10)) return;
|
|
63
|
+
if (!dims.height || dims.height < 10) return;
|
|
64
|
+
|
|
65
|
+
renderChart(svgEl, data, dims, options);
|
|
66
|
+
}, [data, renderChart, options, computedWidth, computedHeight, responsive, loading, error]);
|
|
67
|
+
|
|
68
|
+
if (error) {
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
ref={containerRef}
|
|
72
|
+
className={["w-full rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900 dark:border-rose-900/40 dark:bg-rose-950/30 dark:text-rose-100", className]
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.join(" ")}
|
|
75
|
+
style={containerStyle}
|
|
76
|
+
>
|
|
77
|
+
{String(error)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
ref={containerRef}
|
|
85
|
+
className={["w-full", className].filter(Boolean).join(" ")}
|
|
86
|
+
style={{ ...containerStyle, position: "relative" }}
|
|
87
|
+
>
|
|
88
|
+
{loading ? (
|
|
89
|
+
<div className="h-full w-full rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
90
|
+
<div className="space-y-3">
|
|
91
|
+
<div className="h-4 w-1/3 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
|
|
92
|
+
<div className="h-44 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
) : (
|
|
96
|
+
<svg
|
|
97
|
+
ref={svgRef}
|
|
98
|
+
role="img"
|
|
99
|
+
aria-label={ariaLabel}
|
|
100
|
+
width={computedWidth ?? width ?? "100%"}
|
|
101
|
+
height={computedHeight}
|
|
102
|
+
style={{ ...svgStyle, ...style, display: "block" }}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as d3 from "d3";
|
|
2
|
+
|
|
3
|
+
function clear(svg) {
|
|
4
|
+
d3.select(svg).selectAll("*").remove();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const D3ChartTemplates = {
|
|
8
|
+
lineChart(svg, data, dims, opts = {}) {
|
|
9
|
+
const {
|
|
10
|
+
xKey = "x",
|
|
11
|
+
yKey = "y",
|
|
12
|
+
margin = { top: 12, right: 16, bottom: 28, left: 40 },
|
|
13
|
+
stroke = "#6366F1",
|
|
14
|
+
strokeWidth = 2,
|
|
15
|
+
showAxes = true,
|
|
16
|
+
showGrid = true
|
|
17
|
+
} = opts;
|
|
18
|
+
|
|
19
|
+
clear(svg);
|
|
20
|
+
|
|
21
|
+
const width = dims.width;
|
|
22
|
+
const height = dims.height;
|
|
23
|
+
const innerW = Math.max(0, width - margin.left - margin.right);
|
|
24
|
+
const innerH = Math.max(0, height - margin.top - margin.bottom);
|
|
25
|
+
|
|
26
|
+
const g = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`).append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
|
27
|
+
|
|
28
|
+
const xs = data.map((d) => d?.[xKey]).filter((v) => v != null);
|
|
29
|
+
const ys = data.map((d) => d?.[yKey]).filter((v) => v != null);
|
|
30
|
+
|
|
31
|
+
const xDomain = d3.extent(xs);
|
|
32
|
+
const yDomain = d3.extent(ys);
|
|
33
|
+
|
|
34
|
+
const x = d3.scaleLinear().domain(xDomain).nice().range([0, innerW]);
|
|
35
|
+
const y = d3.scaleLinear().domain(yDomain).nice().range([innerH, 0]);
|
|
36
|
+
|
|
37
|
+
if (showGrid) {
|
|
38
|
+
g.append("g")
|
|
39
|
+
.attr("class", "grid")
|
|
40
|
+
.call(d3.axisLeft(y).ticks(5).tickSize(-innerW).tickFormat(""))
|
|
41
|
+
.call((grid) => grid.selectAll("line").attr("stroke", "currentColor").attr("opacity", 0.12))
|
|
42
|
+
.call((grid) => grid.selectAll("path").attr("stroke", "none"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const line = d3
|
|
46
|
+
.line()
|
|
47
|
+
.x((d) => x(d[xKey]))
|
|
48
|
+
.y((d) => y(d[yKey]))
|
|
49
|
+
.defined((d) => d?.[xKey] != null && d?.[yKey] != null);
|
|
50
|
+
|
|
51
|
+
g.append("path")
|
|
52
|
+
.datum(data)
|
|
53
|
+
.attr("fill", "none")
|
|
54
|
+
.attr("stroke", stroke)
|
|
55
|
+
.attr("stroke-width", strokeWidth)
|
|
56
|
+
.attr("d", line);
|
|
57
|
+
|
|
58
|
+
if (showAxes) {
|
|
59
|
+
g.append("g")
|
|
60
|
+
.attr("transform", `translate(0,${innerH})`)
|
|
61
|
+
.call(d3.axisBottom(x).ticks(6))
|
|
62
|
+
.call((ax) => ax.selectAll("text").attr("font-size", 10));
|
|
63
|
+
|
|
64
|
+
g.append("g")
|
|
65
|
+
.call(d3.axisLeft(y).ticks(5))
|
|
66
|
+
.call((ax) => ax.selectAll("text").attr("font-size", 10));
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
groupedBarChart(svg, data, dims, opts = {}) {
|
|
71
|
+
const {
|
|
72
|
+
groups = [],
|
|
73
|
+
margin = { top: 20, right: 20, bottom: 40, left: 55 },
|
|
74
|
+
xKey = "x",
|
|
75
|
+
colors = ["#6366F1", "#CBD5E1"],
|
|
76
|
+
barRadius = 4,
|
|
77
|
+
yFormat = ",.0f",
|
|
78
|
+
showGrid = true,
|
|
79
|
+
} = opts;
|
|
80
|
+
|
|
81
|
+
clear(svg);
|
|
82
|
+
|
|
83
|
+
const width = dims.width;
|
|
84
|
+
const height = dims.height;
|
|
85
|
+
const innerW = Math.max(0, width - margin.left - margin.right);
|
|
86
|
+
const innerH = Math.max(0, height - margin.top - margin.bottom);
|
|
87
|
+
|
|
88
|
+
const sel = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
|
|
89
|
+
const g = sel.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
|
90
|
+
|
|
91
|
+
const groupKeys = groups.length ? groups : Object.keys(data[0] || {}).filter((k) => k !== xKey);
|
|
92
|
+
|
|
93
|
+
const x0 = d3.scaleBand().domain(data.map((d) => d[xKey])).range([0, innerW]).padding(0.3);
|
|
94
|
+
const x1 = d3.scaleBand().domain(groupKeys).range([0, x0.bandwidth()]).padding(0.05);
|
|
95
|
+
const yMax = d3.max(data, (d) => d3.max(groupKeys, (k) => d[k])) * 1.15;
|
|
96
|
+
const y = d3.scaleLinear().domain([0, yMax]).range([innerH, 0]);
|
|
97
|
+
|
|
98
|
+
g.append("g")
|
|
99
|
+
.attr("transform", `translate(0,${innerH})`)
|
|
100
|
+
.call(d3.axisBottom(x0).tickSize(0))
|
|
101
|
+
.call((ax) => ax.selectAll("text").attr("fill", "#94a3b8").attr("font-size", "11px"))
|
|
102
|
+
.call((ax) => ax.select(".domain").remove());
|
|
103
|
+
|
|
104
|
+
g.append("g")
|
|
105
|
+
.call(d3.axisLeft(y).ticks(5).tickFormat(d3.format(yFormat)).tickSize(showGrid ? -innerW : 0))
|
|
106
|
+
.call((ax) => ax.selectAll("text").attr("fill", "#94a3b8").attr("font-size", "10px"))
|
|
107
|
+
.call((ax) => ax.selectAll(".tick line").attr("stroke", "#e2e8f0").attr("stroke-dasharray", "2,2"))
|
|
108
|
+
.call((ax) => ax.select(".domain").remove());
|
|
109
|
+
|
|
110
|
+
const rows = g.selectAll(".bar-group").data(data).join("g")
|
|
111
|
+
.attr("class", "bar-group")
|
|
112
|
+
.attr("transform", (d) => `translate(${x0(d[xKey])},0)`);
|
|
113
|
+
|
|
114
|
+
groupKeys.forEach((key, i) => {
|
|
115
|
+
rows.append("rect")
|
|
116
|
+
.attr("x", x1(key))
|
|
117
|
+
.attr("y", (d) => y(d[key]))
|
|
118
|
+
.attr("width", x1.bandwidth())
|
|
119
|
+
.attr("height", (d) => innerH - y(d[key]))
|
|
120
|
+
.attr("rx", barRadius)
|
|
121
|
+
.attr("fill", colors[i % colors.length]);
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
|