registry.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998
  1. "use client";
  2. import { toast } from "sonner";
  3. import { findFormValue, getByPath } from "@json-render/core";
  4. import { useData, defineRegistry } from "@json-render/react";
  5. import {
  6. Bar,
  7. BarChart as RechartsBarChart,
  8. CartesianGrid,
  9. Line,
  10. LineChart as RechartsLineChart,
  11. XAxis,
  12. } from "recharts";
  13. import {
  14. ChartContainer,
  15. ChartTooltip,
  16. ChartTooltipContent,
  17. type ChartConfig,
  18. } from "@/components/ui/chart";
  19. // shadcn components
  20. import { Button } from "@/components/ui/button";
  21. import { Input } from "@/components/ui/input";
  22. import { Label } from "@/components/ui/label";
  23. import { Badge } from "@/components/ui/badge";
  24. import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
  25. import { Separator } from "@/components/ui/separator";
  26. import {
  27. Accordion,
  28. AccordionItem,
  29. AccordionTrigger,
  30. AccordionContent,
  31. } from "@/components/ui/accordion";
  32. import {
  33. Table,
  34. TableHeader,
  35. TableBody,
  36. TableHead,
  37. TableRow,
  38. TableCell,
  39. } from "@/components/ui/table";
  40. import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
  41. import { Checkbox } from "@/components/ui/checkbox";
  42. import {
  43. Dialog,
  44. DialogTrigger,
  45. DialogContent,
  46. DialogHeader,
  47. DialogTitle,
  48. DialogDescription,
  49. } from "@/components/ui/dialog";
  50. import {
  51. Drawer,
  52. DrawerTrigger,
  53. DrawerContent,
  54. DrawerHeader,
  55. DrawerTitle,
  56. DrawerDescription,
  57. } from "@/components/ui/drawer";
  58. import {
  59. DropdownMenu,
  60. DropdownMenuTrigger,
  61. DropdownMenuContent,
  62. DropdownMenuItem,
  63. } from "@/components/ui/dropdown-menu";
  64. import {
  65. Pagination,
  66. PaginationContent,
  67. PaginationItem,
  68. PaginationPrevious,
  69. PaginationNext,
  70. PaginationLink,
  71. } from "@/components/ui/pagination";
  72. import {
  73. Popover,
  74. PopoverTrigger,
  75. PopoverContent,
  76. } from "@/components/ui/popover";
  77. import { Progress } from "@/components/ui/progress";
  78. import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
  79. import {
  80. Select,
  81. SelectTrigger,
  82. SelectValue,
  83. SelectContent,
  84. SelectItem,
  85. } from "@/components/ui/select";
  86. import { Skeleton } from "@/components/ui/skeleton";
  87. import { Switch } from "@/components/ui/switch";
  88. import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
  89. import { Textarea } from "@/components/ui/textarea";
  90. import {
  91. Tooltip,
  92. TooltipTrigger,
  93. TooltipContent,
  94. TooltipProvider,
  95. } from "@/components/ui/tooltip";
  96. import { dashboardCatalog } from "./catalog";
  97. // =============================================================================
  98. // Registry
  99. // =============================================================================
  100. export const { registry, handlers, executeAction } = defineRegistry(
  101. dashboardCatalog,
  102. {
  103. components: {
  104. Stack: ({ props, children }) => {
  105. const gapClass =
  106. { sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ??
  107. "gap-4";
  108. return (
  109. <div
  110. className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${gapClass}`}
  111. >
  112. {children}
  113. </div>
  114. );
  115. },
  116. Accordion: ({ props, children }) => (
  117. <Accordion type={props.type ?? "single"} collapsible>
  118. {children}
  119. </Accordion>
  120. ),
  121. AccordionItem: ({ props, children }) => (
  122. <AccordionItem value={props.value}>
  123. <AccordionTrigger>{props.title}</AccordionTrigger>
  124. <AccordionContent>{children}</AccordionContent>
  125. </AccordionItem>
  126. ),
  127. Button: ({ props, onAction, loading }) => (
  128. <Button
  129. variant={props.variant ?? "default"}
  130. disabled={loading || (props.disabled ?? false)}
  131. onClick={() =>
  132. onAction?.({
  133. name: props.action,
  134. params: props.actionParams ?? undefined,
  135. })
  136. }
  137. >
  138. {loading ? "..." : props.label}
  139. </Button>
  140. ),
  141. Input: ({ props }) => {
  142. const { data, set } = useData();
  143. return (
  144. <div className="flex flex-col gap-2">
  145. {props.label ? <Label>{props.label}</Label> : null}
  146. <Input
  147. type={props.type ?? "text"}
  148. value={(getByPath(data, props.valuePath) as string) ?? ""}
  149. placeholder={props.placeholder ?? ""}
  150. onChange={(e) => set(props.valuePath, e.target.value)}
  151. />
  152. </div>
  153. );
  154. },
  155. Form: ({ props, children, onAction }) => (
  156. <form
  157. onSubmit={(e) => {
  158. e.preventDefault();
  159. onAction?.({
  160. name: props.submitAction,
  161. params: props.submitActionParams ?? undefined,
  162. });
  163. }}
  164. className="flex flex-col gap-4"
  165. >
  166. {children}
  167. </form>
  168. ),
  169. Badge: ({ props }) => (
  170. <Badge variant={props.variant ?? "default"}>{props.text}</Badge>
  171. ),
  172. Alert: ({ props }) => (
  173. <Alert variant={props.variant ?? "default"}>
  174. <AlertTitle>{props.title}</AlertTitle>
  175. {props.description ? (
  176. <AlertDescription>{props.description}</AlertDescription>
  177. ) : null}
  178. </Alert>
  179. ),
  180. Separator: () => <Separator />,
  181. Avatar: ({ props }) => (
  182. <Avatar>
  183. {props.src ? (
  184. <AvatarImage src={props.src} alt={props.alt ?? ""} />
  185. ) : null}
  186. <AvatarFallback>{props.fallback}</AvatarFallback>
  187. </Avatar>
  188. ),
  189. Checkbox: ({ props }) => {
  190. const { data, set } = useData();
  191. const checked =
  192. (getByPath(data, props.valuePath) as boolean) ??
  193. props.defaultChecked ??
  194. false;
  195. return (
  196. <div className="flex items-center gap-2">
  197. <Checkbox
  198. id={props.valuePath}
  199. checked={checked}
  200. onCheckedChange={(value) => set(props.valuePath, value)}
  201. />
  202. {props.label ? (
  203. <Label htmlFor={props.valuePath}>{props.label}</Label>
  204. ) : null}
  205. </div>
  206. );
  207. },
  208. Dialog: ({ props, children }) => (
  209. <Dialog>
  210. <DialogTrigger asChild>
  211. <Button variant="outline">{props.trigger}</Button>
  212. </DialogTrigger>
  213. <DialogContent>
  214. <DialogHeader>
  215. <DialogTitle>{props.title}</DialogTitle>
  216. {props.description ? (
  217. <DialogDescription>{props.description}</DialogDescription>
  218. ) : null}
  219. </DialogHeader>
  220. {children}
  221. </DialogContent>
  222. </Dialog>
  223. ),
  224. Drawer: ({ props, children }) => (
  225. <Drawer>
  226. <DrawerTrigger asChild>
  227. <Button variant="outline">{props.trigger}</Button>
  228. </DrawerTrigger>
  229. <DrawerContent>
  230. <DrawerHeader>
  231. <DrawerTitle>{props.title}</DrawerTitle>
  232. {props.description ? (
  233. <DrawerDescription>{props.description}</DrawerDescription>
  234. ) : null}
  235. </DrawerHeader>
  236. <div className="p-4">{children}</div>
  237. </DrawerContent>
  238. </Drawer>
  239. ),
  240. DropdownMenu: ({ props, onAction }) => (
  241. <DropdownMenu>
  242. <DropdownMenuTrigger asChild>
  243. <Button variant="outline">{props.trigger}</Button>
  244. </DropdownMenuTrigger>
  245. <DropdownMenuContent>
  246. {props.items.map((item, i) => (
  247. <DropdownMenuItem
  248. key={i}
  249. onClick={() =>
  250. item.action
  251. ? onAction?.({
  252. name: item.action,
  253. params: item.actionParams ?? undefined,
  254. })
  255. : undefined
  256. }
  257. >
  258. {item.label}
  259. </DropdownMenuItem>
  260. ))}
  261. </DropdownMenuContent>
  262. </DropdownMenu>
  263. ),
  264. Label: ({ props }) => (
  265. <Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
  266. ),
  267. Pagination: ({ props }) => {
  268. const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
  269. return (
  270. <Pagination>
  271. <PaginationContent>
  272. <PaginationItem>
  273. <PaginationPrevious href="#" />
  274. </PaginationItem>
  275. {pages.map((page) => (
  276. <PaginationItem key={page}>
  277. <PaginationLink
  278. href="#"
  279. isActive={page === props.currentPage}
  280. >
  281. {page}
  282. </PaginationLink>
  283. </PaginationItem>
  284. ))}
  285. <PaginationItem>
  286. <PaginationNext href="#" />
  287. </PaginationItem>
  288. </PaginationContent>
  289. </Pagination>
  290. );
  291. },
  292. Popover: ({ props, children }) => (
  293. <Popover>
  294. <PopoverTrigger asChild>
  295. <Button variant="outline">{props.trigger}</Button>
  296. </PopoverTrigger>
  297. <PopoverContent>{children}</PopoverContent>
  298. </Popover>
  299. ),
  300. Progress: ({ props }) => (
  301. <Progress value={props.value} max={props.max ?? 100} />
  302. ),
  303. RadioGroup: ({ props }) => {
  304. const { data, set } = useData();
  305. const value =
  306. (getByPath(data, props.valuePath) as string) ??
  307. props.defaultValue ??
  308. "";
  309. return (
  310. <RadioGroup
  311. value={value}
  312. onValueChange={(v) => set(props.valuePath, v)}
  313. >
  314. {props.options.map((option) => (
  315. <div key={option.value} className="flex items-center gap-2">
  316. <RadioGroupItem value={option.value} id={option.value} />
  317. <Label htmlFor={option.value}>{option.label}</Label>
  318. </div>
  319. ))}
  320. </RadioGroup>
  321. );
  322. },
  323. Select: ({ props }) => {
  324. const { data, set } = useData();
  325. const value = (getByPath(data, props.valuePath) as string) ?? "";
  326. return (
  327. <Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
  328. <SelectTrigger>
  329. <SelectValue placeholder={props.placeholder ?? "Select..."} />
  330. </SelectTrigger>
  331. <SelectContent>
  332. {props.options.map((option) => (
  333. <SelectItem key={option.value} value={option.value}>
  334. {option.label}
  335. </SelectItem>
  336. ))}
  337. </SelectContent>
  338. </Select>
  339. );
  340. },
  341. Skeleton: ({ props }) => (
  342. <Skeleton
  343. className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
  344. />
  345. ),
  346. Spinner: ({ props }) => {
  347. const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
  348. const size = sizes[props.size ?? "md"];
  349. return (
  350. <div
  351. className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
  352. />
  353. );
  354. },
  355. Switch: ({ props }) => {
  356. const { data, set } = useData();
  357. const checked =
  358. (getByPath(data, props.valuePath) as boolean) ??
  359. props.defaultChecked ??
  360. false;
  361. return (
  362. <div className="flex items-center gap-2">
  363. <Switch
  364. id={props.valuePath}
  365. checked={checked}
  366. onCheckedChange={(value) => set(props.valuePath, value)}
  367. />
  368. {props.label ? (
  369. <Label htmlFor={props.valuePath}>{props.label}</Label>
  370. ) : null}
  371. </div>
  372. );
  373. },
  374. Tabs: ({ props, children }) => (
  375. <Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
  376. <TabsList>
  377. {props.tabs.map((tab) => (
  378. <TabsTrigger key={tab.value} value={tab.value}>
  379. {tab.label}
  380. </TabsTrigger>
  381. ))}
  382. </TabsList>
  383. {children}
  384. </Tabs>
  385. ),
  386. TabContent: ({ props, children }) => (
  387. <TabsContent value={props.value}>{children}</TabsContent>
  388. ),
  389. Textarea: ({ props }) => {
  390. const { data, set } = useData();
  391. return (
  392. <div className="flex flex-col gap-2">
  393. {props.label ? <Label>{props.label}</Label> : null}
  394. <Textarea
  395. value={(getByPath(data, props.valuePath) as string) ?? ""}
  396. placeholder={props.placeholder ?? ""}
  397. rows={props.rows ?? 3}
  398. onChange={(e) => set(props.valuePath, e.target.value)}
  399. />
  400. </div>
  401. );
  402. },
  403. Tooltip: ({ props, children }) => (
  404. <TooltipProvider>
  405. <Tooltip>
  406. <TooltipTrigger asChild>{children}</TooltipTrigger>
  407. <TooltipContent>{props.content}</TooltipContent>
  408. </Tooltip>
  409. </TooltipProvider>
  410. ),
  411. // Heading is intentionally not rendered - widgets already have a title bar
  412. Heading: () => null,
  413. Text: ({ props }) => (
  414. <p className={props.muted ? "text-muted-foreground" : ""}>
  415. {props.content}
  416. </p>
  417. ),
  418. Table: ({ props, onAction }) => {
  419. const { data } = useData();
  420. const path = props.dataPath.replace(/\./g, "/");
  421. const rawData = getByPath(data, path);
  422. const items: Array<Record<string, unknown>> = Array.isArray(rawData)
  423. ? rawData
  424. : Array.isArray((rawData as Record<string, unknown>)?.data)
  425. ? ((rawData as Record<string, unknown>).data as Array<
  426. Record<string, unknown>
  427. >)
  428. : Array.isArray((rawData as Record<string, unknown>)?.items)
  429. ? ((rawData as Record<string, unknown>).items as Array<
  430. Record<string, unknown>
  431. >)
  432. : [];
  433. if (items.length === 0) {
  434. return (
  435. <div className="text-center py-4 text-muted-foreground">
  436. {props.emptyMessage ?? "No data"}
  437. </div>
  438. );
  439. }
  440. const hasRowActions = props.rowActions && props.rowActions.length > 0;
  441. return (
  442. <Table>
  443. <TableHeader>
  444. <TableRow>
  445. {props.columns.map((col) => (
  446. <TableHead key={col.key}>{col.label}</TableHead>
  447. ))}
  448. {hasRowActions ? <TableHead>Actions</TableHead> : null}
  449. </TableRow>
  450. </TableHeader>
  451. <TableBody>
  452. {items.map((item, i) => (
  453. <TableRow key={i}>
  454. {props.columns.map((col) => (
  455. <TableCell key={col.key}>
  456. {String(item[col.key] ?? "")}
  457. </TableCell>
  458. ))}
  459. {hasRowActions ? (
  460. <TableCell>
  461. <div className="flex gap-1">
  462. {props.rowActions!.map((rowAction) => (
  463. <Button
  464. key={rowAction.action}
  465. variant={rowAction.variant ?? "ghost"}
  466. size="sm"
  467. onClick={() =>
  468. onAction?.({
  469. name: rowAction.action,
  470. params: { id: item.id as string },
  471. })
  472. }
  473. >
  474. {rowAction.label}
  475. </Button>
  476. ))}
  477. </div>
  478. </TableCell>
  479. ) : null}
  480. </TableRow>
  481. ))}
  482. </TableBody>
  483. </Table>
  484. );
  485. },
  486. BarChart: ({ props }) => {
  487. const { data } = useData();
  488. const path = props.dataPath.replace(/\./g, "/");
  489. const rawData = getByPath(data, path);
  490. const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
  491. ? rawData
  492. : Array.isArray((rawData as Record<string, unknown>)?.data)
  493. ? ((rawData as Record<string, unknown>).data as Array<
  494. Record<string, unknown>
  495. >)
  496. : [];
  497. // Process and aggregate data
  498. const { items, valueKey } = processChartData(
  499. rawItems,
  500. props.xKey,
  501. props.yKey,
  502. props.aggregate,
  503. );
  504. const chartColor = props.color ?? "var(--chart-1)";
  505. const chartConfig = {
  506. [valueKey]: {
  507. label: valueKey,
  508. color: chartColor,
  509. },
  510. } satisfies ChartConfig;
  511. if (items.length === 0) {
  512. return (
  513. <div className="w-full">
  514. <div className="text-center py-4 text-muted-foreground">
  515. No data available
  516. </div>
  517. </div>
  518. );
  519. }
  520. return (
  521. <div className="w-full">
  522. <ChartContainer
  523. config={chartConfig}
  524. className="min-h-[200px] w-full"
  525. style={{ height: props.height ?? 300 }}
  526. >
  527. <RechartsBarChart accessibilityLayer data={items}>
  528. <CartesianGrid vertical={false} />
  529. <XAxis
  530. dataKey="label"
  531. tickLine={false}
  532. tickMargin={10}
  533. axisLine={false}
  534. />
  535. <ChartTooltip content={<ChartTooltipContent />} />
  536. <Bar
  537. dataKey={valueKey}
  538. fill={`var(--color-${valueKey})`}
  539. radius={4}
  540. />
  541. </RechartsBarChart>
  542. </ChartContainer>
  543. </div>
  544. );
  545. },
  546. LineChart: ({ props }) => {
  547. const { data } = useData();
  548. const path = props.dataPath.replace(/\./g, "/");
  549. const rawData = getByPath(data, path);
  550. const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
  551. ? rawData
  552. : Array.isArray((rawData as Record<string, unknown>)?.data)
  553. ? ((rawData as Record<string, unknown>).data as Array<
  554. Record<string, unknown>
  555. >)
  556. : [];
  557. // Process and aggregate data
  558. const { items, valueKey } = processChartData(
  559. rawItems,
  560. props.xKey,
  561. props.yKey,
  562. props.aggregate,
  563. );
  564. const chartColor = props.color ?? "var(--chart-1)";
  565. const chartConfig = {
  566. [valueKey]: {
  567. label: valueKey,
  568. color: chartColor,
  569. },
  570. } satisfies ChartConfig;
  571. if (items.length === 0) {
  572. return (
  573. <div className="w-full">
  574. <div className="text-center py-4 text-muted-foreground">
  575. No data available
  576. </div>
  577. </div>
  578. );
  579. }
  580. return (
  581. <div className="w-full">
  582. <ChartContainer
  583. config={chartConfig}
  584. className="min-h-[200px] w-full"
  585. style={{ height: props.height ?? 300 }}
  586. >
  587. <RechartsLineChart accessibilityLayer data={items}>
  588. <CartesianGrid vertical={false} />
  589. <XAxis
  590. dataKey="label"
  591. tickLine={false}
  592. tickMargin={10}
  593. axisLine={false}
  594. />
  595. <ChartTooltip content={<ChartTooltipContent />} />
  596. <Line
  597. type="monotone"
  598. dataKey={valueKey}
  599. stroke={`var(--color-${valueKey})`}
  600. strokeWidth={2}
  601. dot={false}
  602. />
  603. </RechartsLineChart>
  604. </ChartContainer>
  605. </div>
  606. );
  607. },
  608. },
  609. actions: {
  610. viewCustomers: async (params, setData) => {
  611. const queryParams = new URLSearchParams();
  612. if (params?.limit) queryParams.set("limit", String(params.limit));
  613. if (params?.sort) queryParams.set("sort", String(params.sort));
  614. if (params?.status) queryParams.set("status", String(params.status));
  615. const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
  616. const res = await fetch(url);
  617. const customers = await res.json();
  618. setData((prev) => ({ ...prev, customers }));
  619. },
  620. refreshCustomers: async (params, setData) => {
  621. const queryParams = new URLSearchParams();
  622. if (params?.limit) queryParams.set("limit", String(params.limit));
  623. if (params?.sort) queryParams.set("sort", String(params.sort));
  624. const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
  625. const res = await fetch(url);
  626. const customers = await res.json();
  627. setData((prev) => ({ ...prev, customers }));
  628. },
  629. createCustomer: async (params, setData, data) => {
  630. const name = findFormValue("name", params, data) as string;
  631. const email =
  632. (findFormValue("email", params, data) as string) ||
  633. `${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
  634. const phone = findFormValue("phone", params, data) as
  635. | string
  636. | undefined;
  637. if (!name) {
  638. toast.error("Customer name is required");
  639. return;
  640. }
  641. try {
  642. const res = await fetch("/api/v1/customers", {
  643. method: "POST",
  644. headers: { "Content-Type": "application/json" },
  645. body: JSON.stringify({ name, email, phone }),
  646. });
  647. const customer = await res.json();
  648. if (res.ok) {
  649. toast.success(`Customer "${customer.name}" created`);
  650. const listRes = await fetch("/api/v1/customers");
  651. const customers = await listRes.json();
  652. setData((prev) => ({ ...prev, customers }));
  653. } else {
  654. toast.error(customer.error || "Failed to create customer");
  655. }
  656. } catch (err) {
  657. console.error("Failed to create customer:", err);
  658. toast.error("Failed to create customer");
  659. }
  660. },
  661. deleteCustomer: async (params, setData, data) => {
  662. const customerId =
  663. findFormValue("customerId", params, data) ||
  664. findFormValue("id", params, data);
  665. if (!customerId) {
  666. toast.error("Customer ID required");
  667. return;
  668. }
  669. try {
  670. const res = await fetch(`/api/v1/customers/${customerId}`, {
  671. method: "DELETE",
  672. });
  673. if (res.ok) {
  674. toast.success("Customer deleted");
  675. const listRes = await fetch("/api/v1/customers");
  676. const customers = await listRes.json();
  677. setData((prev) => ({ ...prev, customers }));
  678. } else {
  679. const err = await res.json();
  680. toast.error(err.error || "Failed to delete customer");
  681. }
  682. } catch (err) {
  683. console.error("Failed to delete customer:", err);
  684. toast.error("Failed to delete customer");
  685. }
  686. },
  687. viewInvoices: async (params, setData) => {
  688. const queryParams = new URLSearchParams();
  689. if (params?.status) queryParams.set("status", String(params.status));
  690. const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
  691. const res = await fetch(url);
  692. const invoices = await res.json();
  693. setData((prev) => ({ ...prev, invoices }));
  694. },
  695. refreshInvoices: async (_params, setData) => {
  696. const res = await fetch("/api/v1/invoices");
  697. const invoices = await res.json();
  698. setData((prev) => ({ ...prev, invoices }));
  699. },
  700. createInvoice: async (params, setData) => {
  701. if (!params?.customerId || !params?.dueDate) {
  702. toast.error("Customer ID and due date required");
  703. return;
  704. }
  705. try {
  706. const res = await fetch("/api/v1/invoices", {
  707. method: "POST",
  708. headers: { "Content-Type": "application/json" },
  709. body: JSON.stringify(params),
  710. });
  711. const invoice = await res.json();
  712. if (res.ok) {
  713. toast.success("Invoice created");
  714. const listRes = await fetch("/api/v1/invoices");
  715. const invoices = await listRes.json();
  716. setData((prev) => ({ ...prev, invoices }));
  717. } else {
  718. toast.error(invoice.error || "Failed to create invoice");
  719. }
  720. } catch (err) {
  721. console.error("Failed to create invoice:", err);
  722. toast.error("Failed to create invoice");
  723. }
  724. },
  725. sendInvoice: async (params) => {
  726. if (!params?.invoiceId) {
  727. toast.error("Invoice ID required");
  728. return;
  729. }
  730. const res = await fetch(`/api/v1/invoices/${params.invoiceId}/send`, {
  731. method: "POST",
  732. });
  733. const result = await res.json();
  734. if (res.ok) {
  735. toast.success(result.message || "Invoice sent");
  736. } else {
  737. toast.error(result.error || "Failed to send invoice");
  738. }
  739. },
  740. markInvoicePaid: async (params) => {
  741. if (!params?.invoiceId) {
  742. toast.error("Invoice ID required");
  743. return;
  744. }
  745. const res = await fetch(
  746. `/api/v1/invoices/${params.invoiceId}/mark-paid`,
  747. { method: "POST" },
  748. );
  749. const result = await res.json();
  750. if (res.ok) {
  751. toast.success(result.message || "Invoice marked paid");
  752. } else {
  753. toast.error(result.error || "Failed to mark invoice paid");
  754. }
  755. },
  756. viewExpenses: async (params, setData) => {
  757. const queryParams = new URLSearchParams();
  758. if (params?.status) queryParams.set("status", String(params.status));
  759. const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
  760. const res = await fetch(url);
  761. const expenses = await res.json();
  762. setData((prev) => ({ ...prev, expenses }));
  763. },
  764. refreshExpenses: async (_params, setData) => {
  765. const res = await fetch("/api/v1/expenses");
  766. const expenses = await res.json();
  767. setData((prev) => ({ ...prev, expenses }));
  768. },
  769. createExpense: async (params, setData) => {
  770. if (
  771. !params?.vendor ||
  772. !params?.category ||
  773. params?.amount === undefined
  774. ) {
  775. toast.error("Vendor, category, and amount required");
  776. return;
  777. }
  778. try {
  779. const res = await fetch("/api/v1/expenses", {
  780. method: "POST",
  781. headers: { "Content-Type": "application/json" },
  782. body: JSON.stringify(params),
  783. });
  784. const expense = await res.json();
  785. if (res.ok) {
  786. toast.success("Expense created");
  787. const listRes = await fetch("/api/v1/expenses");
  788. const expenses = await listRes.json();
  789. setData((prev) => ({ ...prev, expenses }));
  790. } else {
  791. toast.error(expense.error || "Failed to create expense");
  792. }
  793. } catch (err) {
  794. console.error("Failed to create expense:", err);
  795. toast.error("Failed to create expense");
  796. }
  797. },
  798. approveExpense: async (params) => {
  799. if (!params?.expenseId) {
  800. toast.error("Expense ID required");
  801. return;
  802. }
  803. const res = await fetch(
  804. `/api/v1/expenses/${params.expenseId}/approve`,
  805. { method: "POST" },
  806. );
  807. const result = await res.json();
  808. if (res.ok) {
  809. toast.success(result.message || "Expense approved");
  810. } else {
  811. toast.error(result.error || "Failed to approve expense");
  812. }
  813. },
  814. },
  815. },
  816. );
  817. // =============================================================================
  818. // Chart Helpers
  819. // =============================================================================
  820. function isISODate(value: unknown): boolean {
  821. if (typeof value !== "string") return false;
  822. return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
  823. }
  824. function formatDateLabel(value: string): string {
  825. const date = new Date(value);
  826. if (isNaN(date.getTime())) return value;
  827. return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
  828. }
  829. function processChartData(
  830. items: Array<Record<string, unknown>>,
  831. xKey: string,
  832. yKey: string,
  833. aggregate: "sum" | "count" | "avg" | null | undefined,
  834. ): { items: Array<Record<string, unknown>>; valueKey: string } {
  835. if (items.length === 0) {
  836. return { items: [], valueKey: yKey };
  837. }
  838. const firstXValue = items[0]?.[xKey];
  839. const isDateKey = isISODate(firstXValue);
  840. if (!aggregate) {
  841. const formatted = items.map((item) => {
  842. const xValue = item[xKey];
  843. return {
  844. ...item,
  845. label:
  846. isDateKey && typeof xValue === "string"
  847. ? formatDateLabel(xValue)
  848. : String(xValue ?? ""),
  849. };
  850. });
  851. return { items: formatted, valueKey: yKey };
  852. }
  853. const groups = new Map<string, Array<Record<string, unknown>>>();
  854. for (const item of items) {
  855. const xValue = item[xKey];
  856. let groupKey: string;
  857. if (isDateKey && typeof xValue === "string") {
  858. groupKey = xValue.split("T")[0] ?? xValue;
  859. } else {
  860. groupKey = String(xValue ?? "unknown");
  861. }
  862. const group = groups.get(groupKey) ?? [];
  863. group.push(item);
  864. groups.set(groupKey, group);
  865. }
  866. const valueKey = aggregate === "count" ? "count" : yKey;
  867. const aggregated: Array<Record<string, unknown>> = [];
  868. const sortedKeys = Array.from(groups.keys()).sort();
  869. for (const key of sortedKeys) {
  870. const group = groups.get(key)!;
  871. let value: number;
  872. if (aggregate === "count") {
  873. value = group.length;
  874. } else if (aggregate === "sum") {
  875. value = group.reduce((sum, item) => {
  876. const v = item[yKey];
  877. return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
  878. }, 0);
  879. } else {
  880. const sum = group.reduce((s, item) => {
  881. const v = item[yKey];
  882. return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
  883. }, 0);
  884. value = group.length > 0 ? sum / group.length : 0;
  885. }
  886. let label: string;
  887. if (isDateKey) {
  888. const date = new Date(key);
  889. label = date.toLocaleDateString("en-US", {
  890. month: "short",
  891. day: "2-digit",
  892. });
  893. } else {
  894. label = key;
  895. }
  896. aggregated.push({
  897. label,
  898. [valueKey]: value,
  899. _groupKey: key,
  900. });
  901. }
  902. return { items: aggregated, valueKey };
  903. }
  904. // =============================================================================
  905. // Fallback Component
  906. // =============================================================================
  907. export function Fallback({ type }: { type: string }) {
  908. return (
  909. <div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
  910. Unknown component: {type}
  911. </div>
  912. );
  913. }