File size: 4,687 Bytes
3f13033 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | "use client";
import { PageHeader, PageSection, Stat, EmptyState, Loading, ErrorState } from "./page-shell";
import { useApi } from "./use-api";
import type { LucideIcon } from "lucide-react";
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function renderValue(v: unknown): React.ReactNode {
if (v === null || v === undefined) return <span className="text-muted-foreground">—</span>;
if (typeof v === "boolean") return v ? "Yes" : "No";
if (typeof v === "string" || typeof v === "number") return String(v);
if (Array.isArray(v)) {
if (v.length === 0) return <span className="text-muted-foreground">None</span>;
return (
<ul className="space-y-1">
{v.map((item, i) => (
<li key={i} className="text-sm text-foreground/80">
{isObject(item) ? <ObjectTable obj={item} /> : String(item)}
</li>
))}
</ul>
);
}
if (isObject(v)) return <ObjectTable obj={v} />;
return String(v);
}
function ObjectTable({ obj }: { obj: Record<string, unknown> }) {
const entries = Object.entries(obj).filter(([, v]) => typeof v !== "function");
if (entries.length === 0) return <span className="text-muted-foreground">Empty</span>;
return (
<div className="overflow-x-auto rounded-lg border border-border bg-muted/10">
<table className="w-full text-sm">
<tbody>
{entries.map(([k, v]) => (
<tr key={k} className="border-b border-border last:border-0">
<td className="w-1/3 p-2.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{k.replace(/([A-Z])/g, " $1").replace(/_/g, " ")}
</td>
<td className="p-2.5 text-foreground/90">{renderValue(v)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function RecordList({
items,
keyFn,
render,
empty,
}: {
items: any[];
keyFn: (item: any) => string;
render: (item: any) => React.ReactNode;
empty?: React.ReactNode;
}) {
if (items.length === 0) return empty || <p className="text-sm text-muted-foreground">No records found.</p>;
return <div className="space-y-3">{items.map((item) => <div key={keyFn(item)}>{render(item)}</div>)}</div>;
}
export function GenericDataPage({
icon: Icon,
title,
subtitle,
endpoint,
listKey,
renderItem,
listItemKey,
stats,
renderOverview,
hideOverview,
}: {
icon: LucideIcon;
title: string;
subtitle: string;
endpoint: string;
listKey?: string;
listItemKey?: (item: any) => string;
renderItem?: (item: any) => React.ReactNode;
stats?: { label: string; path: string }[];
renderOverview?: (data: any) => React.ReactNode;
hideOverview?: boolean;
}) {
const { data, loading, error, refetch } = useApi(endpoint);
if (loading) return <Loading message={`Loading ${title.toLowerCase()}…`} />;
if (error) return <ErrorState error={error} onRetry={refetch} />;
const list = listKey && data ? data[listKey] : data;
const values = stats
? stats.map((s: any) => {
const v = s.path.split(".").reduce((acc: any, k: string) => acc?.[k], data);
return { ...s, value: v };
})
: [];
return (
<div className="mx-auto max-w-7xl px-6 py-8 page-enter">
<PageHeader icon={Icon} title={title} subtitle={subtitle}>
<button onClick={refetch} className="btn btn-ghost text-xs">Refresh</button>
</PageHeader>
{values.length > 0 && (
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-6">
{values.map((s: any) => (
<Stat key={s.label} label={s.label} value={s.value ?? 0} />
))}
</div>
)}
{listKey && Array.isArray(list) ? (
<PageSection title={listKey.replace(/_/g, " ").replace(/\b\w/g, (l: string) => l.toUpperCase())} className="mt-6">
{list.length === 0 ? (
<EmptyState title="No records" description={`The ${title.toLowerCase()} ledger is empty.`} />
) : (
<div className="space-y-3">
{list.map((item: any) => (
<div key={(listItemKey ? listItemKey(item) : item.id) || Math.random()} className="card card-hover p-4">
{renderItem ? renderItem(item) : <ObjectTable obj={item} />}
</div>
))}
</div>
)}
</PageSection>
) : !hideOverview ? (
<PageSection title="Overview" className="mt-6">
{renderOverview ? renderOverview(data) : <ObjectTable obj={data || {}} />}
</PageSection>
) : null}
</div>
);
}
|