components.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771
  1. "use client";
  2. import { useData, type Components } from "@json-render/react";
  3. import { getByPath } from "@json-render/core";
  4. import {
  5. Bar,
  6. BarChart as RechartsBarChart,
  7. CartesianGrid,
  8. Line,
  9. LineChart as RechartsLineChart,
  10. XAxis,
  11. } from "recharts";
  12. import {
  13. ChartContainer,
  14. ChartTooltip,
  15. ChartTooltipContent,
  16. type ChartConfig,
  17. } from "@/components/ui/chart";
  18. // shadcn components
  19. import { Button } from "@/components/ui/button";
  20. import { Input } from "@/components/ui/input";
  21. import { Label } from "@/components/ui/label";
  22. import { Badge } from "@/components/ui/badge";
  23. import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
  24. import { Separator } from "@/components/ui/separator";
  25. import {
  26. Accordion,
  27. AccordionItem,
  28. AccordionTrigger,
  29. AccordionContent,
  30. } from "@/components/ui/accordion";
  31. import {
  32. Table,
  33. TableHeader,
  34. TableBody,
  35. TableHead,
  36. TableRow,
  37. TableCell,
  38. } from "@/components/ui/table";
  39. import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
  40. import { Checkbox } from "@/components/ui/checkbox";
  41. import {
  42. Dialog,
  43. DialogTrigger,
  44. DialogContent,
  45. DialogHeader,
  46. DialogTitle,
  47. DialogDescription,
  48. } from "@/components/ui/dialog";
  49. import {
  50. Drawer,
  51. DrawerTrigger,
  52. DrawerContent,
  53. DrawerHeader,
  54. DrawerTitle,
  55. DrawerDescription,
  56. } from "@/components/ui/drawer";
  57. import {
  58. DropdownMenu,
  59. DropdownMenuTrigger,
  60. DropdownMenuContent,
  61. DropdownMenuItem,
  62. } from "@/components/ui/dropdown-menu";
  63. import {
  64. Pagination,
  65. PaginationContent,
  66. PaginationItem,
  67. PaginationPrevious,
  68. PaginationNext,
  69. PaginationLink,
  70. } from "@/components/ui/pagination";
  71. import {
  72. Popover,
  73. PopoverTrigger,
  74. PopoverContent,
  75. } from "@/components/ui/popover";
  76. import { Progress } from "@/components/ui/progress";
  77. import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
  78. import {
  79. Select,
  80. SelectTrigger,
  81. SelectValue,
  82. SelectContent,
  83. SelectItem,
  84. } from "@/components/ui/select";
  85. import { Skeleton } from "@/components/ui/skeleton";
  86. import { Switch } from "@/components/ui/switch";
  87. import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
  88. import { Textarea } from "@/components/ui/textarea";
  89. import {
  90. Tooltip,
  91. TooltipTrigger,
  92. TooltipContent,
  93. TooltipProvider,
  94. } from "@/components/ui/tooltip";
  95. import { dashboardCatalog } from "../catalog";
  96. // =============================================================================
  97. // Components - Type-safe with Catalog
  98. // =============================================================================
  99. export const components: Components<typeof dashboardCatalog> = {
  100. Stack: ({ props, children }) => {
  101. const gapClass =
  102. { sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
  103. return (
  104. <div
  105. className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${gapClass}`}
  106. >
  107. {children}
  108. </div>
  109. );
  110. },
  111. Accordion: ({ props, children }) => (
  112. <Accordion type={props.type ?? "single"} collapsible>
  113. {children}
  114. </Accordion>
  115. ),
  116. AccordionItem: ({ props, children }) => (
  117. <AccordionItem value={props.value}>
  118. <AccordionTrigger>{props.title}</AccordionTrigger>
  119. <AccordionContent>{children}</AccordionContent>
  120. </AccordionItem>
  121. ),
  122. Button: ({ props, onAction, loading }) => (
  123. <Button
  124. variant={props.variant ?? "default"}
  125. disabled={loading || (props.disabled ?? false)}
  126. onClick={() =>
  127. onAction?.({
  128. name: props.action,
  129. params: props.actionParams ?? undefined,
  130. })
  131. }
  132. >
  133. {loading ? "..." : props.label}
  134. </Button>
  135. ),
  136. Input: ({ props }) => {
  137. const { data, set } = useData();
  138. return (
  139. <div className="flex flex-col gap-2">
  140. {props.label ? <Label>{props.label}</Label> : null}
  141. <Input
  142. type={props.type ?? "text"}
  143. value={(getByPath(data, props.valuePath) as string) ?? ""}
  144. placeholder={props.placeholder ?? ""}
  145. onChange={(e) => set(props.valuePath, e.target.value)}
  146. />
  147. </div>
  148. );
  149. },
  150. Form: ({ props, children, onAction }) => (
  151. <form
  152. onSubmit={(e) => {
  153. e.preventDefault();
  154. onAction?.({
  155. name: props.submitAction,
  156. params: props.submitActionParams ?? undefined,
  157. });
  158. }}
  159. className="flex flex-col gap-4"
  160. >
  161. {children}
  162. </form>
  163. ),
  164. Badge: ({ props }) => (
  165. <Badge variant={props.variant ?? "default"}>{props.text}</Badge>
  166. ),
  167. Alert: ({ props }) => (
  168. <Alert variant={props.variant ?? "default"}>
  169. <AlertTitle>{props.title}</AlertTitle>
  170. {props.description ? (
  171. <AlertDescription>{props.description}</AlertDescription>
  172. ) : null}
  173. </Alert>
  174. ),
  175. Separator: () => <Separator />,
  176. Avatar: ({ props }) => (
  177. <Avatar>
  178. {props.src ? <AvatarImage src={props.src} alt={props.alt ?? ""} /> : null}
  179. <AvatarFallback>{props.fallback}</AvatarFallback>
  180. </Avatar>
  181. ),
  182. Checkbox: ({ props }) => {
  183. const { data, set } = useData();
  184. const checked =
  185. (getByPath(data, props.valuePath) as boolean) ??
  186. props.defaultChecked ??
  187. false;
  188. return (
  189. <div className="flex items-center gap-2">
  190. <Checkbox
  191. id={props.valuePath}
  192. checked={checked}
  193. onCheckedChange={(value) => set(props.valuePath, value)}
  194. />
  195. {props.label ? (
  196. <Label htmlFor={props.valuePath}>{props.label}</Label>
  197. ) : null}
  198. </div>
  199. );
  200. },
  201. Dialog: ({ props, children }) => (
  202. <Dialog>
  203. <DialogTrigger asChild>
  204. <Button variant="outline">{props.trigger}</Button>
  205. </DialogTrigger>
  206. <DialogContent>
  207. <DialogHeader>
  208. <DialogTitle>{props.title}</DialogTitle>
  209. {props.description ? (
  210. <DialogDescription>{props.description}</DialogDescription>
  211. ) : null}
  212. </DialogHeader>
  213. {children}
  214. </DialogContent>
  215. </Dialog>
  216. ),
  217. Drawer: ({ props, children }) => (
  218. <Drawer>
  219. <DrawerTrigger asChild>
  220. <Button variant="outline">{props.trigger}</Button>
  221. </DrawerTrigger>
  222. <DrawerContent>
  223. <DrawerHeader>
  224. <DrawerTitle>{props.title}</DrawerTitle>
  225. {props.description ? (
  226. <DrawerDescription>{props.description}</DrawerDescription>
  227. ) : null}
  228. </DrawerHeader>
  229. <div className="p-4">{children}</div>
  230. </DrawerContent>
  231. </Drawer>
  232. ),
  233. DropdownMenu: ({ props, onAction }) => (
  234. <DropdownMenu>
  235. <DropdownMenuTrigger asChild>
  236. <Button variant="outline">{props.trigger}</Button>
  237. </DropdownMenuTrigger>
  238. <DropdownMenuContent>
  239. {props.items.map((item, i) => (
  240. <DropdownMenuItem
  241. key={i}
  242. onClick={() =>
  243. item.action
  244. ? onAction?.({
  245. name: item.action,
  246. params: item.actionParams ?? undefined,
  247. })
  248. : undefined
  249. }
  250. >
  251. {item.label}
  252. </DropdownMenuItem>
  253. ))}
  254. </DropdownMenuContent>
  255. </DropdownMenu>
  256. ),
  257. Label: ({ props }) => (
  258. <Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
  259. ),
  260. Pagination: ({ props }) => {
  261. const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
  262. return (
  263. <Pagination>
  264. <PaginationContent>
  265. <PaginationItem>
  266. <PaginationPrevious href="#" />
  267. </PaginationItem>
  268. {pages.map((page) => (
  269. <PaginationItem key={page}>
  270. <PaginationLink href="#" isActive={page === props.currentPage}>
  271. {page}
  272. </PaginationLink>
  273. </PaginationItem>
  274. ))}
  275. <PaginationItem>
  276. <PaginationNext href="#" />
  277. </PaginationItem>
  278. </PaginationContent>
  279. </Pagination>
  280. );
  281. },
  282. Popover: ({ props, children }) => (
  283. <Popover>
  284. <PopoverTrigger asChild>
  285. <Button variant="outline">{props.trigger}</Button>
  286. </PopoverTrigger>
  287. <PopoverContent>{children}</PopoverContent>
  288. </Popover>
  289. ),
  290. Progress: ({ props }) => (
  291. <Progress value={props.value} max={props.max ?? 100} />
  292. ),
  293. RadioGroup: ({ props }) => {
  294. const { data, set } = useData();
  295. const value =
  296. (getByPath(data, props.valuePath) as string) ?? props.defaultValue ?? "";
  297. return (
  298. <RadioGroup value={value} onValueChange={(v) => set(props.valuePath, v)}>
  299. {props.options.map((option) => (
  300. <div key={option.value} className="flex items-center gap-2">
  301. <RadioGroupItem value={option.value} id={option.value} />
  302. <Label htmlFor={option.value}>{option.label}</Label>
  303. </div>
  304. ))}
  305. </RadioGroup>
  306. );
  307. },
  308. Select: ({ props }) => {
  309. const { data, set } = useData();
  310. const value = (getByPath(data, props.valuePath) as string) ?? "";
  311. return (
  312. <Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
  313. <SelectTrigger>
  314. <SelectValue placeholder={props.placeholder ?? "Select..."} />
  315. </SelectTrigger>
  316. <SelectContent>
  317. {props.options.map((option) => (
  318. <SelectItem key={option.value} value={option.value}>
  319. {option.label}
  320. </SelectItem>
  321. ))}
  322. </SelectContent>
  323. </Select>
  324. );
  325. },
  326. Skeleton: ({ props }) => (
  327. <Skeleton
  328. className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
  329. />
  330. ),
  331. Spinner: ({ props }) => {
  332. const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
  333. const size = sizes[props.size ?? "md"];
  334. return (
  335. <div
  336. className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
  337. />
  338. );
  339. },
  340. Switch: ({ props }) => {
  341. const { data, set } = useData();
  342. const checked =
  343. (getByPath(data, props.valuePath) as boolean) ??
  344. props.defaultChecked ??
  345. false;
  346. return (
  347. <div className="flex items-center gap-2">
  348. <Switch
  349. id={props.valuePath}
  350. checked={checked}
  351. onCheckedChange={(value) => set(props.valuePath, value)}
  352. />
  353. {props.label ? (
  354. <Label htmlFor={props.valuePath}>{props.label}</Label>
  355. ) : null}
  356. </div>
  357. );
  358. },
  359. Tabs: ({ props, children }) => (
  360. <Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
  361. <TabsList>
  362. {props.tabs.map((tab) => (
  363. <TabsTrigger key={tab.value} value={tab.value}>
  364. {tab.label}
  365. </TabsTrigger>
  366. ))}
  367. </TabsList>
  368. {children}
  369. </Tabs>
  370. ),
  371. TabContent: ({ props, children }) => (
  372. <TabsContent value={props.value}>{children}</TabsContent>
  373. ),
  374. Textarea: ({ props }) => {
  375. const { data, set } = useData();
  376. return (
  377. <div className="flex flex-col gap-2">
  378. {props.label ? <Label>{props.label}</Label> : null}
  379. <Textarea
  380. value={(getByPath(data, props.valuePath) as string) ?? ""}
  381. placeholder={props.placeholder ?? ""}
  382. rows={props.rows ?? 3}
  383. onChange={(e) => set(props.valuePath, e.target.value)}
  384. />
  385. </div>
  386. );
  387. },
  388. Tooltip: ({ props, children }) => (
  389. <TooltipProvider>
  390. <Tooltip>
  391. <TooltipTrigger asChild>{children}</TooltipTrigger>
  392. <TooltipContent>{props.content}</TooltipContent>
  393. </Tooltip>
  394. </TooltipProvider>
  395. ),
  396. // Heading is intentionally not rendered - widgets already have a title bar
  397. Heading: () => null,
  398. Text: ({ props }) => (
  399. <p className={props.muted ? "text-muted-foreground" : ""}>
  400. {props.content}
  401. </p>
  402. ),
  403. Table: ({ props, onAction }) => {
  404. const { data } = useData();
  405. const path = props.dataPath.replace(/\./g, "/");
  406. const rawData = getByPath(data, path);
  407. const items: Array<Record<string, unknown>> = Array.isArray(rawData)
  408. ? rawData
  409. : Array.isArray((rawData as Record<string, unknown>)?.data)
  410. ? ((rawData as Record<string, unknown>).data as Array<
  411. Record<string, unknown>
  412. >)
  413. : Array.isArray((rawData as Record<string, unknown>)?.items)
  414. ? ((rawData as Record<string, unknown>).items as Array<
  415. Record<string, unknown>
  416. >)
  417. : [];
  418. if (items.length === 0) {
  419. return (
  420. <div className="text-center py-4 text-muted-foreground">
  421. {props.emptyMessage ?? "No data"}
  422. </div>
  423. );
  424. }
  425. const hasRowActions = props.rowActions && props.rowActions.length > 0;
  426. return (
  427. <Table>
  428. <TableHeader>
  429. <TableRow>
  430. {props.columns.map((col) => (
  431. <TableHead key={col.key}>{col.label}</TableHead>
  432. ))}
  433. {hasRowActions ? <TableHead>Actions</TableHead> : null}
  434. </TableRow>
  435. </TableHeader>
  436. <TableBody>
  437. {items.map((item, i) => (
  438. <TableRow key={i}>
  439. {props.columns.map((col) => (
  440. <TableCell key={col.key}>
  441. {String(item[col.key] ?? "")}
  442. </TableCell>
  443. ))}
  444. {hasRowActions ? (
  445. <TableCell>
  446. <div className="flex gap-1">
  447. {props.rowActions!.map((rowAction) => (
  448. <Button
  449. key={rowAction.action}
  450. variant={rowAction.variant ?? "ghost"}
  451. size="sm"
  452. onClick={() =>
  453. onAction?.({
  454. name: rowAction.action,
  455. params: { id: item.id as string },
  456. })
  457. }
  458. >
  459. {rowAction.label}
  460. </Button>
  461. ))}
  462. </div>
  463. </TableCell>
  464. ) : null}
  465. </TableRow>
  466. ))}
  467. </TableBody>
  468. </Table>
  469. );
  470. },
  471. BarChart: ({ props }) => {
  472. const { data } = useData();
  473. const path = props.dataPath.replace(/\./g, "/");
  474. const rawData = getByPath(data, path);
  475. const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
  476. ? rawData
  477. : Array.isArray((rawData as Record<string, unknown>)?.data)
  478. ? ((rawData as Record<string, unknown>).data as Array<
  479. Record<string, unknown>
  480. >)
  481. : [];
  482. // Process and aggregate data
  483. const { items, valueKey } = processChartData(
  484. rawItems,
  485. props.xKey,
  486. props.yKey,
  487. props.aggregate,
  488. );
  489. const chartColor = props.color ?? "var(--chart-1)";
  490. const chartConfig = {
  491. [valueKey]: {
  492. label: valueKey,
  493. color: chartColor,
  494. },
  495. } satisfies ChartConfig;
  496. if (items.length === 0) {
  497. return (
  498. <div className="w-full">
  499. <div className="text-center py-4 text-muted-foreground">
  500. No data available
  501. </div>
  502. </div>
  503. );
  504. }
  505. return (
  506. <div className="w-full">
  507. <ChartContainer
  508. config={chartConfig}
  509. className="min-h-[200px] w-full"
  510. style={{ height: props.height ?? 300 }}
  511. >
  512. <RechartsBarChart accessibilityLayer data={items}>
  513. <CartesianGrid vertical={false} />
  514. <XAxis
  515. dataKey="label"
  516. tickLine={false}
  517. tickMargin={10}
  518. axisLine={false}
  519. />
  520. <ChartTooltip content={<ChartTooltipContent />} />
  521. <Bar
  522. dataKey={valueKey}
  523. fill={`var(--color-${valueKey})`}
  524. radius={4}
  525. />
  526. </RechartsBarChart>
  527. </ChartContainer>
  528. </div>
  529. );
  530. },
  531. LineChart: ({ props }) => {
  532. const { data } = useData();
  533. const path = props.dataPath.replace(/\./g, "/");
  534. const rawData = getByPath(data, path);
  535. const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
  536. ? rawData
  537. : Array.isArray((rawData as Record<string, unknown>)?.data)
  538. ? ((rawData as Record<string, unknown>).data as Array<
  539. Record<string, unknown>
  540. >)
  541. : [];
  542. // Process and aggregate data
  543. const { items, valueKey } = processChartData(
  544. rawItems,
  545. props.xKey,
  546. props.yKey,
  547. props.aggregate,
  548. );
  549. const chartColor = props.color ?? "var(--chart-1)";
  550. const chartConfig = {
  551. [valueKey]: {
  552. label: valueKey,
  553. color: chartColor,
  554. },
  555. } satisfies ChartConfig;
  556. if (items.length === 0) {
  557. return (
  558. <div className="w-full">
  559. <div className="text-center py-4 text-muted-foreground">
  560. No data available
  561. </div>
  562. </div>
  563. );
  564. }
  565. return (
  566. <div className="w-full">
  567. <ChartContainer
  568. config={chartConfig}
  569. className="min-h-[200px] w-full"
  570. style={{ height: props.height ?? 300 }}
  571. >
  572. <RechartsLineChart accessibilityLayer data={items}>
  573. <CartesianGrid vertical={false} />
  574. <XAxis
  575. dataKey="label"
  576. tickLine={false}
  577. tickMargin={10}
  578. axisLine={false}
  579. />
  580. <ChartTooltip content={<ChartTooltipContent />} />
  581. <Line
  582. type="monotone"
  583. dataKey={valueKey}
  584. stroke={`var(--color-${valueKey})`}
  585. strokeWidth={2}
  586. dot={false}
  587. />
  588. </RechartsLineChart>
  589. </ChartContainer>
  590. </div>
  591. );
  592. },
  593. };
  594. // =============================================================================
  595. // Chart Helpers
  596. // =============================================================================
  597. function isISODate(value: unknown): boolean {
  598. if (typeof value !== "string") return false;
  599. // Check for ISO date format: YYYY-MM-DDTHH:mm:ss
  600. return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
  601. }
  602. function formatDateLabel(value: string): string {
  603. const date = new Date(value);
  604. if (isNaN(date.getTime())) return value;
  605. // Format as "Mon DD" (e.g., "Feb 04")
  606. return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
  607. }
  608. function processChartData(
  609. items: Array<Record<string, unknown>>,
  610. xKey: string,
  611. yKey: string,
  612. aggregate: "sum" | "count" | "avg" | null | undefined,
  613. ): { items: Array<Record<string, unknown>>; valueKey: string } {
  614. if (items.length === 0) {
  615. return { items: [], valueKey: yKey };
  616. }
  617. // Check if xKey contains date values
  618. const firstXValue = items[0]?.[xKey];
  619. const isDateKey = isISODate(firstXValue);
  620. // If no aggregation, just format dates and return
  621. if (!aggregate) {
  622. const formatted = items.map((item) => {
  623. const xValue = item[xKey];
  624. return {
  625. ...item,
  626. label:
  627. isDateKey && typeof xValue === "string"
  628. ? formatDateLabel(xValue)
  629. : String(xValue ?? ""),
  630. };
  631. });
  632. return { items: formatted, valueKey: yKey };
  633. }
  634. // Group items by xKey (use date string for dates)
  635. const groups = new Map<string, Array<Record<string, unknown>>>();
  636. for (const item of items) {
  637. const xValue = item[xKey];
  638. let groupKey: string;
  639. if (isDateKey && typeof xValue === "string") {
  640. // Group by date (YYYY-MM-DD)
  641. groupKey = xValue.split("T")[0] ?? xValue;
  642. } else {
  643. groupKey = String(xValue ?? "unknown");
  644. }
  645. const group = groups.get(groupKey) ?? [];
  646. group.push(item);
  647. groups.set(groupKey, group);
  648. }
  649. // Aggregate each group
  650. const valueKey = aggregate === "count" ? "count" : yKey;
  651. const aggregated: Array<Record<string, unknown>> = [];
  652. // Sort keys (for dates, this will be chronological)
  653. const sortedKeys = Array.from(groups.keys()).sort();
  654. for (const key of sortedKeys) {
  655. const group = groups.get(key)!;
  656. let value: number;
  657. if (aggregate === "count") {
  658. value = group.length;
  659. } else if (aggregate === "sum") {
  660. value = group.reduce((sum, item) => {
  661. const v = item[yKey];
  662. return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
  663. }, 0);
  664. } else {
  665. // avg
  666. const sum = group.reduce((s, item) => {
  667. const v = item[yKey];
  668. return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
  669. }, 0);
  670. value = group.length > 0 ? sum / group.length : 0;
  671. }
  672. // Format label
  673. let label: string;
  674. if (isDateKey) {
  675. const date = new Date(key);
  676. label = date.toLocaleDateString("en-US", {
  677. month: "short",
  678. day: "2-digit",
  679. });
  680. } else {
  681. label = key;
  682. }
  683. aggregated.push({
  684. label,
  685. [valueKey]: value,
  686. _groupKey: key,
  687. });
  688. }
  689. return { items: aggregated, valueKey };
  690. }
  691. // =============================================================================
  692. // Fallback Component
  693. // =============================================================================
  694. export function Fallback({ type }: { type: string }) {
  695. return (
  696. <div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
  697. Unknown component: {type}
  698. </div>
  699. );
  700. }