registry.tsx 30 KB

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