|
|
@@ -0,0 +1,998 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import { toast } from "sonner";
|
|
|
+import { findFormValue, getByPath } from "@json-render/core";
|
|
|
+import { useData, defineRegistry } from "@json-render/react";
|
|
|
+import {
|
|
|
+ Bar,
|
|
|
+ BarChart as RechartsBarChart,
|
|
|
+ CartesianGrid,
|
|
|
+ Line,
|
|
|
+ LineChart as RechartsLineChart,
|
|
|
+ XAxis,
|
|
|
+} from "recharts";
|
|
|
+import {
|
|
|
+ ChartContainer,
|
|
|
+ ChartTooltip,
|
|
|
+ ChartTooltipContent,
|
|
|
+ type ChartConfig,
|
|
|
+} from "@/components/ui/chart";
|
|
|
+
|
|
|
+// shadcn components
|
|
|
+import { Button } from "@/components/ui/button";
|
|
|
+import { Input } from "@/components/ui/input";
|
|
|
+import { Label } from "@/components/ui/label";
|
|
|
+import { Badge } from "@/components/ui/badge";
|
|
|
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
|
|
+import { Separator } from "@/components/ui/separator";
|
|
|
+import {
|
|
|
+ Accordion,
|
|
|
+ AccordionItem,
|
|
|
+ AccordionTrigger,
|
|
|
+ AccordionContent,
|
|
|
+} from "@/components/ui/accordion";
|
|
|
+import {
|
|
|
+ Table,
|
|
|
+ TableHeader,
|
|
|
+ TableBody,
|
|
|
+ TableHead,
|
|
|
+ TableRow,
|
|
|
+ TableCell,
|
|
|
+} from "@/components/ui/table";
|
|
|
+import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
|
|
+import { Checkbox } from "@/components/ui/checkbox";
|
|
|
+import {
|
|
|
+ Dialog,
|
|
|
+ DialogTrigger,
|
|
|
+ DialogContent,
|
|
|
+ DialogHeader,
|
|
|
+ DialogTitle,
|
|
|
+ DialogDescription,
|
|
|
+} from "@/components/ui/dialog";
|
|
|
+import {
|
|
|
+ Drawer,
|
|
|
+ DrawerTrigger,
|
|
|
+ DrawerContent,
|
|
|
+ DrawerHeader,
|
|
|
+ DrawerTitle,
|
|
|
+ DrawerDescription,
|
|
|
+} from "@/components/ui/drawer";
|
|
|
+import {
|
|
|
+ DropdownMenu,
|
|
|
+ DropdownMenuTrigger,
|
|
|
+ DropdownMenuContent,
|
|
|
+ DropdownMenuItem,
|
|
|
+} from "@/components/ui/dropdown-menu";
|
|
|
+import {
|
|
|
+ Pagination,
|
|
|
+ PaginationContent,
|
|
|
+ PaginationItem,
|
|
|
+ PaginationPrevious,
|
|
|
+ PaginationNext,
|
|
|
+ PaginationLink,
|
|
|
+} from "@/components/ui/pagination";
|
|
|
+import {
|
|
|
+ Popover,
|
|
|
+ PopoverTrigger,
|
|
|
+ PopoverContent,
|
|
|
+} from "@/components/ui/popover";
|
|
|
+import { Progress } from "@/components/ui/progress";
|
|
|
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
|
+import {
|
|
|
+ Select,
|
|
|
+ SelectTrigger,
|
|
|
+ SelectValue,
|
|
|
+ SelectContent,
|
|
|
+ SelectItem,
|
|
|
+} from "@/components/ui/select";
|
|
|
+import { Skeleton } from "@/components/ui/skeleton";
|
|
|
+import { Switch } from "@/components/ui/switch";
|
|
|
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|
|
+import { Textarea } from "@/components/ui/textarea";
|
|
|
+import {
|
|
|
+ Tooltip,
|
|
|
+ TooltipTrigger,
|
|
|
+ TooltipContent,
|
|
|
+ TooltipProvider,
|
|
|
+} from "@/components/ui/tooltip";
|
|
|
+
|
|
|
+import { dashboardCatalog } from "./catalog";
|
|
|
+
|
|
|
+// =============================================================================
|
|
|
+// Registry
|
|
|
+// =============================================================================
|
|
|
+
|
|
|
+export const { registry, handlers, executeAction } = defineRegistry(
|
|
|
+ dashboardCatalog,
|
|
|
+ {
|
|
|
+ components: {
|
|
|
+ Stack: ({ props, children }) => {
|
|
|
+ const gapClass =
|
|
|
+ { sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ??
|
|
|
+ "gap-4";
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${gapClass}`}
|
|
|
+ >
|
|
|
+ {children}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ Accordion: ({ props, children }) => (
|
|
|
+ <Accordion type={props.type ?? "single"} collapsible>
|
|
|
+ {children}
|
|
|
+ </Accordion>
|
|
|
+ ),
|
|
|
+
|
|
|
+ AccordionItem: ({ props, children }) => (
|
|
|
+ <AccordionItem value={props.value}>
|
|
|
+ <AccordionTrigger>{props.title}</AccordionTrigger>
|
|
|
+ <AccordionContent>{children}</AccordionContent>
|
|
|
+ </AccordionItem>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Button: ({ props, onAction, loading }) => (
|
|
|
+ <Button
|
|
|
+ variant={props.variant ?? "default"}
|
|
|
+ disabled={loading || (props.disabled ?? false)}
|
|
|
+ onClick={() =>
|
|
|
+ onAction?.({
|
|
|
+ name: props.action,
|
|
|
+ params: props.actionParams ?? undefined,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {loading ? "..." : props.label}
|
|
|
+ </Button>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Input: ({ props }) => {
|
|
|
+ const { data, set } = useData();
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col gap-2">
|
|
|
+ {props.label ? <Label>{props.label}</Label> : null}
|
|
|
+ <Input
|
|
|
+ type={props.type ?? "text"}
|
|
|
+ value={(getByPath(data, props.valuePath) as string) ?? ""}
|
|
|
+ placeholder={props.placeholder ?? ""}
|
|
|
+ onChange={(e) => set(props.valuePath, e.target.value)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ Form: ({ props, children, onAction }) => (
|
|
|
+ <form
|
|
|
+ onSubmit={(e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ onAction?.({
|
|
|
+ name: props.submitAction,
|
|
|
+ params: props.submitActionParams ?? undefined,
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ className="flex flex-col gap-4"
|
|
|
+ >
|
|
|
+ {children}
|
|
|
+ </form>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Badge: ({ props }) => (
|
|
|
+ <Badge variant={props.variant ?? "default"}>{props.text}</Badge>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Alert: ({ props }) => (
|
|
|
+ <Alert variant={props.variant ?? "default"}>
|
|
|
+ <AlertTitle>{props.title}</AlertTitle>
|
|
|
+ {props.description ? (
|
|
|
+ <AlertDescription>{props.description}</AlertDescription>
|
|
|
+ ) : null}
|
|
|
+ </Alert>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Separator: () => <Separator />,
|
|
|
+
|
|
|
+ Avatar: ({ props }) => (
|
|
|
+ <Avatar>
|
|
|
+ {props.src ? (
|
|
|
+ <AvatarImage src={props.src} alt={props.alt ?? ""} />
|
|
|
+ ) : null}
|
|
|
+ <AvatarFallback>{props.fallback}</AvatarFallback>
|
|
|
+ </Avatar>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Checkbox: ({ props }) => {
|
|
|
+ const { data, set } = useData();
|
|
|
+ const checked =
|
|
|
+ (getByPath(data, props.valuePath) as boolean) ??
|
|
|
+ props.defaultChecked ??
|
|
|
+ false;
|
|
|
+ return (
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <Checkbox
|
|
|
+ id={props.valuePath}
|
|
|
+ checked={checked}
|
|
|
+ onCheckedChange={(value) => set(props.valuePath, value)}
|
|
|
+ />
|
|
|
+ {props.label ? (
|
|
|
+ <Label htmlFor={props.valuePath}>{props.label}</Label>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ Dialog: ({ props, children }) => (
|
|
|
+ <Dialog>
|
|
|
+ <DialogTrigger asChild>
|
|
|
+ <Button variant="outline">{props.trigger}</Button>
|
|
|
+ </DialogTrigger>
|
|
|
+ <DialogContent>
|
|
|
+ <DialogHeader>
|
|
|
+ <DialogTitle>{props.title}</DialogTitle>
|
|
|
+ {props.description ? (
|
|
|
+ <DialogDescription>{props.description}</DialogDescription>
|
|
|
+ ) : null}
|
|
|
+ </DialogHeader>
|
|
|
+ {children}
|
|
|
+ </DialogContent>
|
|
|
+ </Dialog>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Drawer: ({ props, children }) => (
|
|
|
+ <Drawer>
|
|
|
+ <DrawerTrigger asChild>
|
|
|
+ <Button variant="outline">{props.trigger}</Button>
|
|
|
+ </DrawerTrigger>
|
|
|
+ <DrawerContent>
|
|
|
+ <DrawerHeader>
|
|
|
+ <DrawerTitle>{props.title}</DrawerTitle>
|
|
|
+ {props.description ? (
|
|
|
+ <DrawerDescription>{props.description}</DrawerDescription>
|
|
|
+ ) : null}
|
|
|
+ </DrawerHeader>
|
|
|
+ <div className="p-4">{children}</div>
|
|
|
+ </DrawerContent>
|
|
|
+ </Drawer>
|
|
|
+ ),
|
|
|
+
|
|
|
+ DropdownMenu: ({ props, onAction }) => (
|
|
|
+ <DropdownMenu>
|
|
|
+ <DropdownMenuTrigger asChild>
|
|
|
+ <Button variant="outline">{props.trigger}</Button>
|
|
|
+ </DropdownMenuTrigger>
|
|
|
+ <DropdownMenuContent>
|
|
|
+ {props.items.map((item, i) => (
|
|
|
+ <DropdownMenuItem
|
|
|
+ key={i}
|
|
|
+ onClick={() =>
|
|
|
+ item.action
|
|
|
+ ? onAction?.({
|
|
|
+ name: item.action,
|
|
|
+ params: item.actionParams ?? undefined,
|
|
|
+ })
|
|
|
+ : undefined
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {item.label}
|
|
|
+ </DropdownMenuItem>
|
|
|
+ ))}
|
|
|
+ </DropdownMenuContent>
|
|
|
+ </DropdownMenu>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Label: ({ props }) => (
|
|
|
+ <Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Pagination: ({ props }) => {
|
|
|
+ const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
|
|
|
+ return (
|
|
|
+ <Pagination>
|
|
|
+ <PaginationContent>
|
|
|
+ <PaginationItem>
|
|
|
+ <PaginationPrevious href="#" />
|
|
|
+ </PaginationItem>
|
|
|
+ {pages.map((page) => (
|
|
|
+ <PaginationItem key={page}>
|
|
|
+ <PaginationLink
|
|
|
+ href="#"
|
|
|
+ isActive={page === props.currentPage}
|
|
|
+ >
|
|
|
+ {page}
|
|
|
+ </PaginationLink>
|
|
|
+ </PaginationItem>
|
|
|
+ ))}
|
|
|
+ <PaginationItem>
|
|
|
+ <PaginationNext href="#" />
|
|
|
+ </PaginationItem>
|
|
|
+ </PaginationContent>
|
|
|
+ </Pagination>
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ Popover: ({ props, children }) => (
|
|
|
+ <Popover>
|
|
|
+ <PopoverTrigger asChild>
|
|
|
+ <Button variant="outline">{props.trigger}</Button>
|
|
|
+ </PopoverTrigger>
|
|
|
+ <PopoverContent>{children}</PopoverContent>
|
|
|
+ </Popover>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Progress: ({ props }) => (
|
|
|
+ <Progress value={props.value} max={props.max ?? 100} />
|
|
|
+ ),
|
|
|
+
|
|
|
+ RadioGroup: ({ props }) => {
|
|
|
+ const { data, set } = useData();
|
|
|
+ const value =
|
|
|
+ (getByPath(data, props.valuePath) as string) ??
|
|
|
+ props.defaultValue ??
|
|
|
+ "";
|
|
|
+ return (
|
|
|
+ <RadioGroup
|
|
|
+ value={value}
|
|
|
+ onValueChange={(v) => set(props.valuePath, v)}
|
|
|
+ >
|
|
|
+ {props.options.map((option) => (
|
|
|
+ <div key={option.value} className="flex items-center gap-2">
|
|
|
+ <RadioGroupItem value={option.value} id={option.value} />
|
|
|
+ <Label htmlFor={option.value}>{option.label}</Label>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </RadioGroup>
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ Select: ({ props }) => {
|
|
|
+ const { data, set } = useData();
|
|
|
+ const value = (getByPath(data, props.valuePath) as string) ?? "";
|
|
|
+ return (
|
|
|
+ <Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
|
|
|
+ <SelectTrigger>
|
|
|
+ <SelectValue placeholder={props.placeholder ?? "Select..."} />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ {props.options.map((option) => (
|
|
|
+ <SelectItem key={option.value} value={option.value}>
|
|
|
+ {option.label}
|
|
|
+ </SelectItem>
|
|
|
+ ))}
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ Skeleton: ({ props }) => (
|
|
|
+ <Skeleton
|
|
|
+ className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+
|
|
|
+ Spinner: ({ props }) => {
|
|
|
+ const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
|
|
|
+ const size = sizes[props.size ?? "md"];
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ Switch: ({ props }) => {
|
|
|
+ const { data, set } = useData();
|
|
|
+ const checked =
|
|
|
+ (getByPath(data, props.valuePath) as boolean) ??
|
|
|
+ props.defaultChecked ??
|
|
|
+ false;
|
|
|
+ return (
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <Switch
|
|
|
+ id={props.valuePath}
|
|
|
+ checked={checked}
|
|
|
+ onCheckedChange={(value) => set(props.valuePath, value)}
|
|
|
+ />
|
|
|
+ {props.label ? (
|
|
|
+ <Label htmlFor={props.valuePath}>{props.label}</Label>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ Tabs: ({ props, children }) => (
|
|
|
+ <Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
|
|
|
+ <TabsList>
|
|
|
+ {props.tabs.map((tab) => (
|
|
|
+ <TabsTrigger key={tab.value} value={tab.value}>
|
|
|
+ {tab.label}
|
|
|
+ </TabsTrigger>
|
|
|
+ ))}
|
|
|
+ </TabsList>
|
|
|
+ {children}
|
|
|
+ </Tabs>
|
|
|
+ ),
|
|
|
+
|
|
|
+ TabContent: ({ props, children }) => (
|
|
|
+ <TabsContent value={props.value}>{children}</TabsContent>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Textarea: ({ props }) => {
|
|
|
+ const { data, set } = useData();
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col gap-2">
|
|
|
+ {props.label ? <Label>{props.label}</Label> : null}
|
|
|
+ <Textarea
|
|
|
+ value={(getByPath(data, props.valuePath) as string) ?? ""}
|
|
|
+ placeholder={props.placeholder ?? ""}
|
|
|
+ rows={props.rows ?? 3}
|
|
|
+ onChange={(e) => set(props.valuePath, e.target.value)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ Tooltip: ({ props, children }) => (
|
|
|
+ <TooltipProvider>
|
|
|
+ <Tooltip>
|
|
|
+ <TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
|
+ <TooltipContent>{props.content}</TooltipContent>
|
|
|
+ </Tooltip>
|
|
|
+ </TooltipProvider>
|
|
|
+ ),
|
|
|
+
|
|
|
+ // Heading is intentionally not rendered - widgets already have a title bar
|
|
|
+ Heading: () => null,
|
|
|
+
|
|
|
+ Text: ({ props }) => (
|
|
|
+ <p className={props.muted ? "text-muted-foreground" : ""}>
|
|
|
+ {props.content}
|
|
|
+ </p>
|
|
|
+ ),
|
|
|
+
|
|
|
+ Table: ({ props, onAction }) => {
|
|
|
+ const { data } = useData();
|
|
|
+ const path = props.dataPath.replace(/\./g, "/");
|
|
|
+ const rawData = getByPath(data, path);
|
|
|
+
|
|
|
+ const items: Array<Record<string, unknown>> = Array.isArray(rawData)
|
|
|
+ ? rawData
|
|
|
+ : Array.isArray((rawData as Record<string, unknown>)?.data)
|
|
|
+ ? ((rawData as Record<string, unknown>).data as Array<
|
|
|
+ Record<string, unknown>
|
|
|
+ >)
|
|
|
+ : Array.isArray((rawData as Record<string, unknown>)?.items)
|
|
|
+ ? ((rawData as Record<string, unknown>).items as Array<
|
|
|
+ Record<string, unknown>
|
|
|
+ >)
|
|
|
+ : [];
|
|
|
+
|
|
|
+ if (items.length === 0) {
|
|
|
+ return (
|
|
|
+ <div className="text-center py-4 text-muted-foreground">
|
|
|
+ {props.emptyMessage ?? "No data"}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const hasRowActions = props.rowActions && props.rowActions.length > 0;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ {props.columns.map((col) => (
|
|
|
+ <TableHead key={col.key}>{col.label}</TableHead>
|
|
|
+ ))}
|
|
|
+ {hasRowActions ? <TableHead>Actions</TableHead> : null}
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {items.map((item, i) => (
|
|
|
+ <TableRow key={i}>
|
|
|
+ {props.columns.map((col) => (
|
|
|
+ <TableCell key={col.key}>
|
|
|
+ {String(item[col.key] ?? "")}
|
|
|
+ </TableCell>
|
|
|
+ ))}
|
|
|
+ {hasRowActions ? (
|
|
|
+ <TableCell>
|
|
|
+ <div className="flex gap-1">
|
|
|
+ {props.rowActions!.map((rowAction) => (
|
|
|
+ <Button
|
|
|
+ key={rowAction.action}
|
|
|
+ variant={rowAction.variant ?? "ghost"}
|
|
|
+ size="sm"
|
|
|
+ onClick={() =>
|
|
|
+ onAction?.({
|
|
|
+ name: rowAction.action,
|
|
|
+ params: { id: item.id as string },
|
|
|
+ })
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {rowAction.label}
|
|
|
+ </Button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </TableCell>
|
|
|
+ ) : null}
|
|
|
+ </TableRow>
|
|
|
+ ))}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ BarChart: ({ props }) => {
|
|
|
+ const { data } = useData();
|
|
|
+ const path = props.dataPath.replace(/\./g, "/");
|
|
|
+ const rawData = getByPath(data, path);
|
|
|
+
|
|
|
+ const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
|
|
+ ? rawData
|
|
|
+ : Array.isArray((rawData as Record<string, unknown>)?.data)
|
|
|
+ ? ((rawData as Record<string, unknown>).data as Array<
|
|
|
+ Record<string, unknown>
|
|
|
+ >)
|
|
|
+ : [];
|
|
|
+
|
|
|
+ // Process and aggregate data
|
|
|
+ const { items, valueKey } = processChartData(
|
|
|
+ rawItems,
|
|
|
+ props.xKey,
|
|
|
+ props.yKey,
|
|
|
+ props.aggregate,
|
|
|
+ );
|
|
|
+
|
|
|
+ const chartColor = props.color ?? "var(--chart-1)";
|
|
|
+
|
|
|
+ const chartConfig = {
|
|
|
+ [valueKey]: {
|
|
|
+ label: valueKey,
|
|
|
+ color: chartColor,
|
|
|
+ },
|
|
|
+ } satisfies ChartConfig;
|
|
|
+
|
|
|
+ if (items.length === 0) {
|
|
|
+ return (
|
|
|
+ <div className="w-full">
|
|
|
+ <div className="text-center py-4 text-muted-foreground">
|
|
|
+ No data available
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="w-full">
|
|
|
+ <ChartContainer
|
|
|
+ config={chartConfig}
|
|
|
+ className="min-h-[200px] w-full"
|
|
|
+ style={{ height: props.height ?? 300 }}
|
|
|
+ >
|
|
|
+ <RechartsBarChart accessibilityLayer data={items}>
|
|
|
+ <CartesianGrid vertical={false} />
|
|
|
+ <XAxis
|
|
|
+ dataKey="label"
|
|
|
+ tickLine={false}
|
|
|
+ tickMargin={10}
|
|
|
+ axisLine={false}
|
|
|
+ />
|
|
|
+ <ChartTooltip content={<ChartTooltipContent />} />
|
|
|
+ <Bar
|
|
|
+ dataKey={valueKey}
|
|
|
+ fill={`var(--color-${valueKey})`}
|
|
|
+ radius={4}
|
|
|
+ />
|
|
|
+ </RechartsBarChart>
|
|
|
+ </ChartContainer>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ LineChart: ({ props }) => {
|
|
|
+ const { data } = useData();
|
|
|
+ const path = props.dataPath.replace(/\./g, "/");
|
|
|
+ const rawData = getByPath(data, path);
|
|
|
+
|
|
|
+ const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
|
|
+ ? rawData
|
|
|
+ : Array.isArray((rawData as Record<string, unknown>)?.data)
|
|
|
+ ? ((rawData as Record<string, unknown>).data as Array<
|
|
|
+ Record<string, unknown>
|
|
|
+ >)
|
|
|
+ : [];
|
|
|
+
|
|
|
+ // Process and aggregate data
|
|
|
+ const { items, valueKey } = processChartData(
|
|
|
+ rawItems,
|
|
|
+ props.xKey,
|
|
|
+ props.yKey,
|
|
|
+ props.aggregate,
|
|
|
+ );
|
|
|
+
|
|
|
+ const chartColor = props.color ?? "var(--chart-1)";
|
|
|
+
|
|
|
+ const chartConfig = {
|
|
|
+ [valueKey]: {
|
|
|
+ label: valueKey,
|
|
|
+ color: chartColor,
|
|
|
+ },
|
|
|
+ } satisfies ChartConfig;
|
|
|
+
|
|
|
+ if (items.length === 0) {
|
|
|
+ return (
|
|
|
+ <div className="w-full">
|
|
|
+ <div className="text-center py-4 text-muted-foreground">
|
|
|
+ No data available
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="w-full">
|
|
|
+ <ChartContainer
|
|
|
+ config={chartConfig}
|
|
|
+ className="min-h-[200px] w-full"
|
|
|
+ style={{ height: props.height ?? 300 }}
|
|
|
+ >
|
|
|
+ <RechartsLineChart accessibilityLayer data={items}>
|
|
|
+ <CartesianGrid vertical={false} />
|
|
|
+ <XAxis
|
|
|
+ dataKey="label"
|
|
|
+ tickLine={false}
|
|
|
+ tickMargin={10}
|
|
|
+ axisLine={false}
|
|
|
+ />
|
|
|
+ <ChartTooltip content={<ChartTooltipContent />} />
|
|
|
+ <Line
|
|
|
+ type="monotone"
|
|
|
+ dataKey={valueKey}
|
|
|
+ stroke={`var(--color-${valueKey})`}
|
|
|
+ strokeWidth={2}
|
|
|
+ dot={false}
|
|
|
+ />
|
|
|
+ </RechartsLineChart>
|
|
|
+ </ChartContainer>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ },
|
|
|
+ },
|
|
|
+
|
|
|
+ actions: {
|
|
|
+ viewCustomers: async (params, setData) => {
|
|
|
+ const queryParams = new URLSearchParams();
|
|
|
+ if (params?.limit) queryParams.set("limit", String(params.limit));
|
|
|
+ if (params?.sort) queryParams.set("sort", String(params.sort));
|
|
|
+ if (params?.status) queryParams.set("status", String(params.status));
|
|
|
+ const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
|
|
|
+ const res = await fetch(url);
|
|
|
+ const customers = await res.json();
|
|
|
+ setData((prev) => ({ ...prev, customers }));
|
|
|
+ },
|
|
|
+
|
|
|
+ refreshCustomers: async (params, setData) => {
|
|
|
+ const queryParams = new URLSearchParams();
|
|
|
+ if (params?.limit) queryParams.set("limit", String(params.limit));
|
|
|
+ if (params?.sort) queryParams.set("sort", String(params.sort));
|
|
|
+ const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
|
|
|
+ const res = await fetch(url);
|
|
|
+ const customers = await res.json();
|
|
|
+ setData((prev) => ({ ...prev, customers }));
|
|
|
+ },
|
|
|
+
|
|
|
+ createCustomer: async (params, setData, data) => {
|
|
|
+ const name = findFormValue("name", params, data) as string;
|
|
|
+ const email =
|
|
|
+ (findFormValue("email", params, data) as string) ||
|
|
|
+ `${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
|
|
|
+ const phone = findFormValue("phone", params, data) as
|
|
|
+ | string
|
|
|
+ | undefined;
|
|
|
+
|
|
|
+ if (!name) {
|
|
|
+ toast.error("Customer name is required");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch("/api/v1/customers", {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify({ name, email, phone }),
|
|
|
+ });
|
|
|
+ const customer = await res.json();
|
|
|
+ if (res.ok) {
|
|
|
+ toast.success(`Customer "${customer.name}" created`);
|
|
|
+ const listRes = await fetch("/api/v1/customers");
|
|
|
+ const customers = await listRes.json();
|
|
|
+ setData((prev) => ({ ...prev, customers }));
|
|
|
+ } else {
|
|
|
+ toast.error(customer.error || "Failed to create customer");
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error("Failed to create customer:", err);
|
|
|
+ toast.error("Failed to create customer");
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ deleteCustomer: async (params, setData, data) => {
|
|
|
+ const customerId =
|
|
|
+ findFormValue("customerId", params, data) ||
|
|
|
+ findFormValue("id", params, data);
|
|
|
+ if (!customerId) {
|
|
|
+ toast.error("Customer ID required");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await fetch(`/api/v1/customers/${customerId}`, {
|
|
|
+ method: "DELETE",
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ toast.success("Customer deleted");
|
|
|
+ const listRes = await fetch("/api/v1/customers");
|
|
|
+ const customers = await listRes.json();
|
|
|
+ setData((prev) => ({ ...prev, customers }));
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ toast.error(err.error || "Failed to delete customer");
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error("Failed to delete customer:", err);
|
|
|
+ toast.error("Failed to delete customer");
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ viewInvoices: async (params, setData) => {
|
|
|
+ const queryParams = new URLSearchParams();
|
|
|
+ if (params?.status) queryParams.set("status", String(params.status));
|
|
|
+ const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
|
|
|
+ const res = await fetch(url);
|
|
|
+ const invoices = await res.json();
|
|
|
+ setData((prev) => ({ ...prev, invoices }));
|
|
|
+ },
|
|
|
+
|
|
|
+ refreshInvoices: async (_params, setData) => {
|
|
|
+ const res = await fetch("/api/v1/invoices");
|
|
|
+ const invoices = await res.json();
|
|
|
+ setData((prev) => ({ ...prev, invoices }));
|
|
|
+ },
|
|
|
+
|
|
|
+ createInvoice: async (params, setData) => {
|
|
|
+ if (!params?.customerId || !params?.dueDate) {
|
|
|
+ toast.error("Customer ID and due date required");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await fetch("/api/v1/invoices", {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify(params),
|
|
|
+ });
|
|
|
+ const invoice = await res.json();
|
|
|
+ if (res.ok) {
|
|
|
+ toast.success("Invoice created");
|
|
|
+ const listRes = await fetch("/api/v1/invoices");
|
|
|
+ const invoices = await listRes.json();
|
|
|
+ setData((prev) => ({ ...prev, invoices }));
|
|
|
+ } else {
|
|
|
+ toast.error(invoice.error || "Failed to create invoice");
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error("Failed to create invoice:", err);
|
|
|
+ toast.error("Failed to create invoice");
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ sendInvoice: async (params) => {
|
|
|
+ if (!params?.invoiceId) {
|
|
|
+ toast.error("Invoice ID required");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const res = await fetch(`/api/v1/invoices/${params.invoiceId}/send`, {
|
|
|
+ method: "POST",
|
|
|
+ });
|
|
|
+ const result = await res.json();
|
|
|
+ if (res.ok) {
|
|
|
+ toast.success(result.message || "Invoice sent");
|
|
|
+ } else {
|
|
|
+ toast.error(result.error || "Failed to send invoice");
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ markInvoicePaid: async (params) => {
|
|
|
+ if (!params?.invoiceId) {
|
|
|
+ toast.error("Invoice ID required");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const res = await fetch(
|
|
|
+ `/api/v1/invoices/${params.invoiceId}/mark-paid`,
|
|
|
+ { method: "POST" },
|
|
|
+ );
|
|
|
+ const result = await res.json();
|
|
|
+ if (res.ok) {
|
|
|
+ toast.success(result.message || "Invoice marked paid");
|
|
|
+ } else {
|
|
|
+ toast.error(result.error || "Failed to mark invoice paid");
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ viewExpenses: async (params, setData) => {
|
|
|
+ const queryParams = new URLSearchParams();
|
|
|
+ if (params?.status) queryParams.set("status", String(params.status));
|
|
|
+ const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
|
|
|
+ const res = await fetch(url);
|
|
|
+ const expenses = await res.json();
|
|
|
+ setData((prev) => ({ ...prev, expenses }));
|
|
|
+ },
|
|
|
+
|
|
|
+ refreshExpenses: async (_params, setData) => {
|
|
|
+ const res = await fetch("/api/v1/expenses");
|
|
|
+ const expenses = await res.json();
|
|
|
+ setData((prev) => ({ ...prev, expenses }));
|
|
|
+ },
|
|
|
+
|
|
|
+ createExpense: async (params, setData) => {
|
|
|
+ if (
|
|
|
+ !params?.vendor ||
|
|
|
+ !params?.category ||
|
|
|
+ params?.amount === undefined
|
|
|
+ ) {
|
|
|
+ toast.error("Vendor, category, and amount required");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await fetch("/api/v1/expenses", {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify(params),
|
|
|
+ });
|
|
|
+ const expense = await res.json();
|
|
|
+ if (res.ok) {
|
|
|
+ toast.success("Expense created");
|
|
|
+ const listRes = await fetch("/api/v1/expenses");
|
|
|
+ const expenses = await listRes.json();
|
|
|
+ setData((prev) => ({ ...prev, expenses }));
|
|
|
+ } else {
|
|
|
+ toast.error(expense.error || "Failed to create expense");
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error("Failed to create expense:", err);
|
|
|
+ toast.error("Failed to create expense");
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ approveExpense: async (params) => {
|
|
|
+ if (!params?.expenseId) {
|
|
|
+ toast.error("Expense ID required");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const res = await fetch(
|
|
|
+ `/api/v1/expenses/${params.expenseId}/approve`,
|
|
|
+ { method: "POST" },
|
|
|
+ );
|
|
|
+ const result = await res.json();
|
|
|
+ if (res.ok) {
|
|
|
+ toast.success(result.message || "Expense approved");
|
|
|
+ } else {
|
|
|
+ toast.error(result.error || "Failed to approve expense");
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+);
|
|
|
+
|
|
|
+// =============================================================================
|
|
|
+// Chart Helpers
|
|
|
+// =============================================================================
|
|
|
+
|
|
|
+function isISODate(value: unknown): boolean {
|
|
|
+ if (typeof value !== "string") return false;
|
|
|
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
|
|
|
+}
|
|
|
+
|
|
|
+function formatDateLabel(value: string): string {
|
|
|
+ const date = new Date(value);
|
|
|
+ if (isNaN(date.getTime())) return value;
|
|
|
+ return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
|
|
|
+}
|
|
|
+
|
|
|
+function processChartData(
|
|
|
+ items: Array<Record<string, unknown>>,
|
|
|
+ xKey: string,
|
|
|
+ yKey: string,
|
|
|
+ aggregate: "sum" | "count" | "avg" | null | undefined,
|
|
|
+): { items: Array<Record<string, unknown>>; valueKey: string } {
|
|
|
+ if (items.length === 0) {
|
|
|
+ return { items: [], valueKey: yKey };
|
|
|
+ }
|
|
|
+
|
|
|
+ const firstXValue = items[0]?.[xKey];
|
|
|
+ const isDateKey = isISODate(firstXValue);
|
|
|
+
|
|
|
+ if (!aggregate) {
|
|
|
+ const formatted = items.map((item) => {
|
|
|
+ const xValue = item[xKey];
|
|
|
+ return {
|
|
|
+ ...item,
|
|
|
+ label:
|
|
|
+ isDateKey && typeof xValue === "string"
|
|
|
+ ? formatDateLabel(xValue)
|
|
|
+ : String(xValue ?? ""),
|
|
|
+ };
|
|
|
+ });
|
|
|
+ return { items: formatted, valueKey: yKey };
|
|
|
+ }
|
|
|
+
|
|
|
+ const groups = new Map<string, Array<Record<string, unknown>>>();
|
|
|
+
|
|
|
+ for (const item of items) {
|
|
|
+ const xValue = item[xKey];
|
|
|
+ let groupKey: string;
|
|
|
+
|
|
|
+ if (isDateKey && typeof xValue === "string") {
|
|
|
+ groupKey = xValue.split("T")[0] ?? xValue;
|
|
|
+ } else {
|
|
|
+ groupKey = String(xValue ?? "unknown");
|
|
|
+ }
|
|
|
+
|
|
|
+ const group = groups.get(groupKey) ?? [];
|
|
|
+ group.push(item);
|
|
|
+ groups.set(groupKey, group);
|
|
|
+ }
|
|
|
+
|
|
|
+ const valueKey = aggregate === "count" ? "count" : yKey;
|
|
|
+ const aggregated: Array<Record<string, unknown>> = [];
|
|
|
+ const sortedKeys = Array.from(groups.keys()).sort();
|
|
|
+
|
|
|
+ for (const key of sortedKeys) {
|
|
|
+ const group = groups.get(key)!;
|
|
|
+ let value: number;
|
|
|
+
|
|
|
+ if (aggregate === "count") {
|
|
|
+ value = group.length;
|
|
|
+ } else if (aggregate === "sum") {
|
|
|
+ value = group.reduce((sum, item) => {
|
|
|
+ const v = item[yKey];
|
|
|
+ return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
|
|
|
+ }, 0);
|
|
|
+ } else {
|
|
|
+ const sum = group.reduce((s, item) => {
|
|
|
+ const v = item[yKey];
|
|
|
+ return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
|
|
|
+ }, 0);
|
|
|
+ value = group.length > 0 ? sum / group.length : 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ let label: string;
|
|
|
+ if (isDateKey) {
|
|
|
+ const date = new Date(key);
|
|
|
+ label = date.toLocaleDateString("en-US", {
|
|
|
+ month: "short",
|
|
|
+ day: "2-digit",
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ label = key;
|
|
|
+ }
|
|
|
+
|
|
|
+ aggregated.push({
|
|
|
+ label,
|
|
|
+ [valueKey]: value,
|
|
|
+ _groupKey: key,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return { items: aggregated, valueKey };
|
|
|
+}
|
|
|
+
|
|
|
+// =============================================================================
|
|
|
+// Fallback Component
|
|
|
+// =============================================================================
|
|
|
+
|
|
|
+export function Fallback({ type }: { type: string }) {
|
|
|
+ return (
|
|
|
+ <div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
|
|
|
+ Unknown component: {type}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|