registry.tsx 30 KB

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