"use client";
import { toast } from "sonner";
import { findFormValue, getByPath } from "@json-render/core";
import { useStateStore, 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 (
{children}
);
},
Accordion: ({ props, children }) => (
{children}
),
AccordionItem: ({ props, children }) => (
{props.title}
{children}
),
Button: ({ props, emit, loading }) => (
emit?.("press")}
>
{loading ? "..." : props.label}
),
Input: ({ props }) => {
const { state, set } = useStateStore();
return (
{props.label ? {props.label} : null}
set(props.valuePath, e.target.value)}
/>
);
},
Form: ({ children, emit }) => (
),
Badge: ({ props }) => (
{props.text}
),
Alert: ({ props }) => (
{props.title}
{props.description ? (
{props.description}
) : null}
),
Separator: () => ,
Avatar: ({ props }) => (
{props.src ? (
) : null}
{props.fallback}
),
Checkbox: ({ props }) => {
const { state, set } = useStateStore();
const checked =
(getByPath(state, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
set(props.valuePath, value)}
/>
{props.label ? (
{props.label}
) : null}
);
},
Dialog: ({ props, children }) => (
{props.trigger}
{props.title}
{props.description ? (
{props.description}
) : null}
{children}
),
Drawer: ({ props, children }) => (
{props.trigger}
{props.title}
{props.description ? (
{props.description}
) : null}
{children}
),
DropdownMenu: ({ props }) => (
{props.trigger}
{props.items.map((item, i) => (
{item.label}
))}
),
Label: ({ props }) => (
{props.text}
),
Pagination: ({ props }) => {
const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
return (
{pages.map((page) => (
{page}
))}
);
},
Popover: ({ props, children }) => (
{props.trigger}
{children}
),
Progress: ({ props }) => (
),
RadioGroup: ({ props }) => {
const { state, set } = useStateStore();
const value =
(getByPath(state, props.valuePath) as string) ??
props.defaultValue ??
"";
return (
set(props.valuePath, v)}
>
{props.options.map((option) => (
{option.label}
))}
);
},
Select: ({ props }) => {
const { state, set } = useStateStore();
const value = (getByPath(state, props.valuePath) as string) ?? "";
return (
set(props.valuePath, v)}>
{props.options.map((option) => (
{option.label}
))}
);
},
Skeleton: ({ props }) => (
),
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 (
);
},
Switch: ({ props }) => {
const { state, set } = useStateStore();
const checked =
(getByPath(state, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
set(props.valuePath, value)}
/>
{props.label ? (
{props.label}
) : null}
);
},
Tabs: ({ props, children }) => (
{props.tabs.map((tab) => (
{tab.label}
))}
{children}
),
TabContent: ({ props, children }) => (
{children}
),
Textarea: ({ props }) => {
const { state, set } = useStateStore();
return (
{props.label ? {props.label} : null}
);
},
Tooltip: ({ props, children }) => (
{children}
{props.content}
),
// Heading is intentionally not rendered - widgets already have a title bar
Heading: () => null,
Text: ({ props }) => (
{props.content}
),
Table: ({ props }) => {
const { state } = useStateStore();
const path = props.statePath.replace(/\./g, "/");
const rawData = getByPath(state, path);
const items: Array> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record)?.data)
? ((rawData as Record).data as Array<
Record
>)
: Array.isArray((rawData as Record)?.items)
? ((rawData as Record).items as Array<
Record
>)
: [];
if (items.length === 0) {
return (
{props.emptyMessage ?? "No data"}
);
}
const hasRowActions = props.rowActions && props.rowActions.length > 0;
return (
{props.columns.map((col) => (
{col.label}
))}
{hasRowActions ? Actions : null}
{items.map((item, i) => (
{props.columns.map((col) => (
{String(item[col.key] ?? "")}
))}
{hasRowActions ? (
{props.rowActions!.map((rowAction) => (
{rowAction.label}
))}
) : null}
))}
);
},
BarChart: ({ props }) => {
const { state } = useStateStore();
const path = props.statePath.replace(/\./g, "/");
const rawData = getByPath(state, path);
const rawItems: Array> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record)?.data)
? ((rawData as Record).data as Array<
Record
>)
: [];
// 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 (
);
}
return (
} />
);
},
LineChart: ({ props }) => {
const { state } = useStateStore();
const path = props.statePath.replace(/\./g, "/");
const rawData = getByPath(state, path);
const rawItems: Array> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record)?.data)
? ((rawData as Record).data as Array<
Record
>)
: [];
// 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 (
);
}
return (
} />
);
},
},
actions: {
viewCustomers: async (params, setState) => {
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();
setState((prev) => ({ ...prev, customers }));
},
refreshCustomers: async (params, setState) => {
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();
setState((prev) => ({ ...prev, customers }));
},
createCustomer: async (params, setState, state) => {
const name = findFormValue("name", params, state) as string;
const email =
(findFormValue("email", params, state) as string) ||
`${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
const phone = findFormValue("phone", params, state) 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();
setState((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, setState, state) => {
const customerId =
findFormValue("customerId", params, state) ||
findFormValue("id", params, state);
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();
setState((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, setState) => {
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();
setState((prev) => ({ ...prev, invoices }));
},
refreshInvoices: async (_params, setState) => {
const res = await fetch("/api/v1/invoices");
const invoices = await res.json();
setState((prev) => ({ ...prev, invoices }));
},
createInvoice: async (params, setState) => {
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();
setState((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, setState) => {
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();
setState((prev) => ({ ...prev, expenses }));
},
refreshExpenses: async (_params, setState) => {
const res = await fetch("/api/v1/expenses");
const expenses = await res.json();
setState((prev) => ({ ...prev, expenses }));
},
createExpense: async (params, setState) => {
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();
setState((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>,
xKey: string,
yKey: string,
aggregate: "sum" | "count" | "avg" | null | undefined,
): { items: Array>; 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>>();
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> = [];
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 (
Unknown component: {type}
);
}