|
|
@@ -0,0 +1,716 @@
|
|
|
+'use client';
|
|
|
+
|
|
|
+import React, { type ReactNode } from 'react';
|
|
|
+import { type ComponentRenderProps } from '@json-render/react';
|
|
|
+import { useData, useFieldValidation } from '@json-render/react';
|
|
|
+import { getByPath } from '@json-render/core';
|
|
|
+
|
|
|
+// Helper to get value from data model
|
|
|
+function useResolvedValue<T>(value: T | { path: string } | null | undefined): T | undefined {
|
|
|
+ const { data } = useData();
|
|
|
+
|
|
|
+ if (value === null || value === undefined) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof value === 'object' && 'path' in value) {
|
|
|
+ return getByPath(data, value.path) as T | undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ return value as T;
|
|
|
+}
|
|
|
+
|
|
|
+// Styles
|
|
|
+const styles = {
|
|
|
+ card: {
|
|
|
+ backgroundColor: '#fff',
|
|
|
+ borderRadius: '8px',
|
|
|
+ border: '1px solid #e5e7eb',
|
|
|
+ overflow: 'hidden',
|
|
|
+ },
|
|
|
+ cardHeader: {
|
|
|
+ padding: '16px 20px',
|
|
|
+ borderBottom: '1px solid #e5e7eb',
|
|
|
+ },
|
|
|
+ cardBody: (padding: string | null | undefined) => ({
|
|
|
+ padding:
|
|
|
+ padding === 'none' ? 0 :
|
|
|
+ padding === 'sm' ? '12px' :
|
|
|
+ padding === 'lg' ? '24px' : '16px',
|
|
|
+ }),
|
|
|
+ metric: {
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'column' as const,
|
|
|
+ gap: '4px',
|
|
|
+ },
|
|
|
+ metricLabel: {
|
|
|
+ fontSize: '14px',
|
|
|
+ color: '#6b7280',
|
|
|
+ },
|
|
|
+ metricValue: {
|
|
|
+ fontSize: '32px',
|
|
|
+ fontWeight: 600,
|
|
|
+ color: '#111827',
|
|
|
+ },
|
|
|
+ metricTrend: (trend: string | null | undefined) => ({
|
|
|
+ fontSize: '14px',
|
|
|
+ color: trend === 'up' ? '#10b981' : trend === 'down' ? '#ef4444' : '#6b7280',
|
|
|
+ }),
|
|
|
+};
|
|
|
+
|
|
|
+// Card component
|
|
|
+export function Card({ element, children }: ComponentRenderProps) {
|
|
|
+ const { title, description, padding } = element.props as {
|
|
|
+ title?: string | null;
|
|
|
+ description?: string | null;
|
|
|
+ padding?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div style={styles.card}>
|
|
|
+ {(title || description) && (
|
|
|
+ <div style={styles.cardHeader}>
|
|
|
+ {title && (
|
|
|
+ <h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>{title}</h3>
|
|
|
+ )}
|
|
|
+ {description && (
|
|
|
+ <p style={{ margin: '4px 0 0', fontSize: '14px', color: '#6b7280' }}>
|
|
|
+ {description}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <div style={styles.cardBody(padding)}>{children}</div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Heading component
|
|
|
+export function Heading({ element }: ComponentRenderProps) {
|
|
|
+ const { text, level } = element.props as {
|
|
|
+ text: string;
|
|
|
+ level?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const Tag = (level || 'h2') as keyof JSX.IntrinsicElements;
|
|
|
+ const sizes: Record<string, string> = {
|
|
|
+ h1: '28px',
|
|
|
+ h2: '24px',
|
|
|
+ h3: '20px',
|
|
|
+ h4: '16px',
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Tag style={{ margin: '0 0 16px', fontSize: sizes[level || 'h2'], fontWeight: 600 }}>
|
|
|
+ {text}
|
|
|
+ </Tag>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Text component
|
|
|
+export function Text({ element }: ComponentRenderProps) {
|
|
|
+ const { content, variant } = element.props as {
|
|
|
+ content: string;
|
|
|
+ variant?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const colors: Record<string, string> = {
|
|
|
+ default: '#111827',
|
|
|
+ muted: '#6b7280',
|
|
|
+ success: '#10b981',
|
|
|
+ warning: '#f59e0b',
|
|
|
+ error: '#ef4444',
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <p style={{ margin: 0, color: colors[variant || 'default'] }}>{content}</p>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Metric component
|
|
|
+export function Metric({ element }: ComponentRenderProps) {
|
|
|
+ const { label, valuePath, format, trend, trendValue } = element.props as {
|
|
|
+ label: string;
|
|
|
+ valuePath: string;
|
|
|
+ format?: string | null;
|
|
|
+ trend?: string | null;
|
|
|
+ trendValue?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const { data } = useData();
|
|
|
+ const rawValue = getByPath(data, valuePath);
|
|
|
+
|
|
|
+ let displayValue = String(rawValue ?? '—');
|
|
|
+ if (format === 'currency' && typeof rawValue === 'number') {
|
|
|
+ displayValue = new Intl.NumberFormat('en-US', {
|
|
|
+ style: 'currency',
|
|
|
+ currency: 'USD',
|
|
|
+ }).format(rawValue);
|
|
|
+ } else if (format === 'percent' && typeof rawValue === 'number') {
|
|
|
+ displayValue = new Intl.NumberFormat('en-US', {
|
|
|
+ style: 'percent',
|
|
|
+ minimumFractionDigits: 1,
|
|
|
+ }).format(rawValue);
|
|
|
+ } else if (format === 'number' && typeof rawValue === 'number') {
|
|
|
+ displayValue = new Intl.NumberFormat('en-US').format(rawValue);
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div style={styles.metric}>
|
|
|
+ <span style={styles.metricLabel}>{label}</span>
|
|
|
+ <span style={styles.metricValue}>{displayValue}</span>
|
|
|
+ {(trend || trendValue) && (
|
|
|
+ <span style={styles.metricTrend(trend)}>
|
|
|
+ {trend === 'up' ? '↑' : trend === 'down' ? '↓' : ''} {trendValue}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Chart component (simplified placeholder)
|
|
|
+export function Chart({ element }: ComponentRenderProps) {
|
|
|
+ const { title, dataPath, type } = element.props as {
|
|
|
+ title?: string | null;
|
|
|
+ dataPath: string;
|
|
|
+ type?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const { data } = useData();
|
|
|
+ const chartData = getByPath(data, dataPath) as Array<{ label: string; value: number }> | undefined;
|
|
|
+
|
|
|
+ if (!chartData || !Array.isArray(chartData)) {
|
|
|
+ return <div style={{ padding: '20px', color: '#6b7280' }}>No chart data</div>;
|
|
|
+ }
|
|
|
+
|
|
|
+ const maxValue = Math.max(...chartData.map((d) => d.value));
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ {title && (
|
|
|
+ <h4 style={{ margin: '0 0 16px', fontSize: '14px', fontWeight: 600 }}>{title}</h4>
|
|
|
+ )}
|
|
|
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', height: '120px' }}>
|
|
|
+ {chartData.map((d, i) => (
|
|
|
+ <div
|
|
|
+ key={i}
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'column',
|
|
|
+ alignItems: 'center',
|
|
|
+ gap: '4px',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: `${(d.value / maxValue) * 100}%`,
|
|
|
+ backgroundColor: '#6366f1',
|
|
|
+ borderRadius: '4px 4px 0 0',
|
|
|
+ minHeight: '4px',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <span style={{ fontSize: '12px', color: '#6b7280' }}>{d.label}</span>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Table component
|
|
|
+export function Table({ element }: ComponentRenderProps) {
|
|
|
+ const { title, dataPath, columns } = element.props as {
|
|
|
+ title?: string | null;
|
|
|
+ dataPath: string;
|
|
|
+ columns: Array<{ key: string; label: string; format?: string | null }>;
|
|
|
+ };
|
|
|
+
|
|
|
+ const { data } = useData();
|
|
|
+ const tableData = getByPath(data, dataPath) as Array<Record<string, unknown>> | undefined;
|
|
|
+
|
|
|
+ if (!tableData || !Array.isArray(tableData)) {
|
|
|
+ return <div style={{ padding: '20px', color: '#6b7280' }}>No data</div>;
|
|
|
+ }
|
|
|
+
|
|
|
+ const formatCell = (value: unknown, format?: string | null) => {
|
|
|
+ if (value === null || value === undefined) return '—';
|
|
|
+ if (format === 'currency' && typeof value === 'number') {
|
|
|
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value);
|
|
|
+ }
|
|
|
+ if (format === 'date' && typeof value === 'string') {
|
|
|
+ return new Date(value).toLocaleDateString();
|
|
|
+ }
|
|
|
+ if (format === 'badge') {
|
|
|
+ const colors: Record<string, { bg: string; text: string }> = {
|
|
|
+ completed: { bg: '#d1fae5', text: '#065f46' },
|
|
|
+ pending: { bg: '#fef3c7', text: '#92400e' },
|
|
|
+ failed: { bg: '#fee2e2', text: '#991b1b' },
|
|
|
+ };
|
|
|
+ const style = colors[String(value).toLowerCase()] || { bg: '#f3f4f6', text: '#374151' };
|
|
|
+ return (
|
|
|
+ <span
|
|
|
+ style={{
|
|
|
+ padding: '2px 8px',
|
|
|
+ borderRadius: '12px',
|
|
|
+ fontSize: '12px',
|
|
|
+ fontWeight: 500,
|
|
|
+ backgroundColor: style.bg,
|
|
|
+ color: style.text,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {String(value)}
|
|
|
+ </span>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return String(value);
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div>
|
|
|
+ {title && (
|
|
|
+ <h4 style={{ margin: '0 0 16px', fontSize: '14px', fontWeight: 600 }}>{title}</h4>
|
|
|
+ )}
|
|
|
+ <table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ {columns.map((col) => (
|
|
|
+ <th
|
|
|
+ key={col.key}
|
|
|
+ style={{
|
|
|
+ textAlign: 'left',
|
|
|
+ padding: '12px 8px',
|
|
|
+ borderBottom: '1px solid #e5e7eb',
|
|
|
+ fontSize: '12px',
|
|
|
+ fontWeight: 600,
|
|
|
+ color: '#6b7280',
|
|
|
+ textTransform: 'uppercase',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {col.label}
|
|
|
+ </th>
|
|
|
+ ))}
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {tableData.map((row, i) => (
|
|
|
+ <tr key={i}>
|
|
|
+ {columns.map((col) => (
|
|
|
+ <td
|
|
|
+ key={col.key}
|
|
|
+ style={{
|
|
|
+ padding: '12px 8px',
|
|
|
+ borderBottom: '1px solid #e5e7eb',
|
|
|
+ fontSize: '14px',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {formatCell(row[col.key], col.format)}
|
|
|
+ </td>
|
|
|
+ ))}
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Button component
|
|
|
+export function Button({ element, onAction, loading }: ComponentRenderProps) {
|
|
|
+ const { label, variant, action, disabled } = element.props as {
|
|
|
+ label: string;
|
|
|
+ variant?: string | null;
|
|
|
+ action: { name: string };
|
|
|
+ disabled?: boolean | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const variants: Record<string, React.CSSProperties> = {
|
|
|
+ primary: { backgroundColor: '#6366f1', color: '#fff', border: 'none' },
|
|
|
+ secondary: { backgroundColor: '#fff', color: '#374151', border: '1px solid #d1d5db' },
|
|
|
+ danger: { backgroundColor: '#dc2626', color: '#fff', border: 'none' },
|
|
|
+ ghost: { backgroundColor: 'transparent', color: '#6b7280', border: 'none' },
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleClick = () => {
|
|
|
+ if (!disabled && action && onAction) {
|
|
|
+ onAction(action);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <button
|
|
|
+ onClick={handleClick}
|
|
|
+ disabled={!!disabled || loading}
|
|
|
+ style={{
|
|
|
+ padding: '8px 16px',
|
|
|
+ borderRadius: '6px',
|
|
|
+ fontSize: '14px',
|
|
|
+ fontWeight: 500,
|
|
|
+ cursor: disabled ? 'not-allowed' : 'pointer',
|
|
|
+ opacity: disabled ? 0.5 : 1,
|
|
|
+ ...variants[variant || 'primary'],
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {loading ? 'Loading...' : label}
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Alert component
|
|
|
+export function Alert({ element }: ComponentRenderProps) {
|
|
|
+ const { message, variant, dismissible } = element.props as {
|
|
|
+ message: string | { path: string };
|
|
|
+ variant?: string | null;
|
|
|
+ dismissible?: boolean | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const resolvedMessage = useResolvedValue(message);
|
|
|
+
|
|
|
+ const defaultStyle = { bg: '#eff6ff', border: '#bfdbfe', text: '#1e40af' };
|
|
|
+ const variants: Record<string, { bg: string; border: string; text: string }> = {
|
|
|
+ info: defaultStyle,
|
|
|
+ success: { bg: '#d1fae5', border: '#a7f3d0', text: '#065f46' },
|
|
|
+ warning: { bg: '#fef3c7', border: '#fcd34d', text: '#92400e' },
|
|
|
+ error: { bg: '#fee2e2', border: '#fca5a5', text: '#991b1b' },
|
|
|
+ };
|
|
|
+
|
|
|
+ const style = variants[variant || 'info'] ?? defaultStyle;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ padding: '12px 16px',
|
|
|
+ borderRadius: '8px',
|
|
|
+ backgroundColor: style.bg,
|
|
|
+ border: `1px solid ${style.border}`,
|
|
|
+ color: style.text,
|
|
|
+ fontSize: '14px',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {resolvedMessage}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Grid component
|
|
|
+export function Grid({ element, children }: ComponentRenderProps) {
|
|
|
+ const { columns, gap } = element.props as {
|
|
|
+ columns?: number | null;
|
|
|
+ gap?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const gaps: Record<string, string> = {
|
|
|
+ none: '0',
|
|
|
+ sm: '8px',
|
|
|
+ md: '16px',
|
|
|
+ lg: '24px',
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ display: 'grid',
|
|
|
+ gridTemplateColumns: `repeat(${columns || 2}, 1fr)`,
|
|
|
+ gap: gaps[gap || 'md'],
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {children}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Stack component
|
|
|
+export function Stack({ element, children }: ComponentRenderProps) {
|
|
|
+ const { direction, gap, align } = element.props as {
|
|
|
+ direction?: string | null;
|
|
|
+ gap?: string | null;
|
|
|
+ align?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const gaps: Record<string, string> = {
|
|
|
+ none: '0',
|
|
|
+ sm: '8px',
|
|
|
+ md: '16px',
|
|
|
+ lg: '24px',
|
|
|
+ };
|
|
|
+
|
|
|
+ const alignments: Record<string, string> = {
|
|
|
+ start: 'flex-start',
|
|
|
+ center: 'center',
|
|
|
+ end: 'flex-end',
|
|
|
+ stretch: 'stretch',
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: direction === 'horizontal' ? 'row' : 'column',
|
|
|
+ gap: gaps[gap || 'md'],
|
|
|
+ alignItems: alignments[align || 'stretch'],
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {children}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Divider component
|
|
|
+export function Divider({ element }: ComponentRenderProps) {
|
|
|
+ const { orientation } = element.props as {
|
|
|
+ orientation?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ if (orientation === 'vertical') {
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: '1px',
|
|
|
+ backgroundColor: '#e5e7eb',
|
|
|
+ alignSelf: 'stretch',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <hr
|
|
|
+ style={{
|
|
|
+ border: 'none',
|
|
|
+ borderTop: '1px solid #e5e7eb',
|
|
|
+ margin: '16px 0',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Badge component
|
|
|
+export function Badge({ element }: ComponentRenderProps) {
|
|
|
+ const { text, variant } = element.props as {
|
|
|
+ text: string | { path: string };
|
|
|
+ variant?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const resolvedText = useResolvedValue(text);
|
|
|
+
|
|
|
+ const defaultBadgeStyle = { bg: '#f3f4f6', text: '#374151' };
|
|
|
+ const variants: Record<string, { bg: string; text: string }> = {
|
|
|
+ default: defaultBadgeStyle,
|
|
|
+ success: { bg: '#d1fae5', text: '#065f46' },
|
|
|
+ warning: { bg: '#fef3c7', text: '#92400e' },
|
|
|
+ error: { bg: '#fee2e2', text: '#991b1b' },
|
|
|
+ info: { bg: '#dbeafe', text: '#1e40af' },
|
|
|
+ };
|
|
|
+
|
|
|
+ const style = variants[variant || 'default'] ?? defaultBadgeStyle;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <span
|
|
|
+ style={{
|
|
|
+ display: 'inline-block',
|
|
|
+ padding: '2px 8px',
|
|
|
+ borderRadius: '12px',
|
|
|
+ fontSize: '12px',
|
|
|
+ fontWeight: 500,
|
|
|
+ backgroundColor: style.bg,
|
|
|
+ color: style.text,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {resolvedText}
|
|
|
+ </span>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// TextField component
|
|
|
+export function TextField({ element }: ComponentRenderProps) {
|
|
|
+ const { label, valuePath, placeholder, type, checks, validateOn } = element.props as {
|
|
|
+ label: string;
|
|
|
+ valuePath: string;
|
|
|
+ placeholder?: string | null;
|
|
|
+ type?: string | null;
|
|
|
+ checks?: Array<{ fn: string; message: string }> | null;
|
|
|
+ validateOn?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const { data, set } = useData();
|
|
|
+ const value = getByPath(data, valuePath) as string | undefined;
|
|
|
+
|
|
|
+ const { errors, validate, touch } = useFieldValidation(valuePath, {
|
|
|
+ checks: checks ?? undefined,
|
|
|
+ validateOn: (validateOn as 'change' | 'blur' | 'submit') ?? 'blur',
|
|
|
+ });
|
|
|
+
|
|
|
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
+ set(valuePath, e.target.value);
|
|
|
+ if (validateOn === 'change') {
|
|
|
+ validate();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleBlur = () => {
|
|
|
+ touch();
|
|
|
+ if (validateOn === 'blur' || !validateOn) {
|
|
|
+ validate();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
|
|
+ <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
|
|
|
+ {label}
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ type={type || 'text'}
|
|
|
+ value={value ?? ''}
|
|
|
+ onChange={handleChange}
|
|
|
+ onBlur={handleBlur}
|
|
|
+ placeholder={placeholder ?? ''}
|
|
|
+ style={{
|
|
|
+ padding: '8px 12px',
|
|
|
+ borderRadius: '6px',
|
|
|
+ border: errors.length > 0 ? '1px solid #ef4444' : '1px solid #d1d5db',
|
|
|
+ fontSize: '14px',
|
|
|
+ outline: 'none',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ {errors.map((error, i) => (
|
|
|
+ <span key={i} style={{ fontSize: '12px', color: '#ef4444' }}>
|
|
|
+ {error}
|
|
|
+ </span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Select component
|
|
|
+export function Select({ element }: ComponentRenderProps) {
|
|
|
+ const { label, valuePath, options, placeholder } = element.props as {
|
|
|
+ label: string;
|
|
|
+ valuePath: string;
|
|
|
+ options: Array<{ value: string; label: string }>;
|
|
|
+ placeholder?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const { data, set } = useData();
|
|
|
+ const value = getByPath(data, valuePath) as string | undefined;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
|
|
+ <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
|
|
|
+ {label}
|
|
|
+ </label>
|
|
|
+ <select
|
|
|
+ value={value ?? ''}
|
|
|
+ onChange={(e) => set(valuePath, e.target.value)}
|
|
|
+ style={{
|
|
|
+ padding: '8px 12px',
|
|
|
+ borderRadius: '6px',
|
|
|
+ border: '1px solid #d1d5db',
|
|
|
+ fontSize: '14px',
|
|
|
+ outline: 'none',
|
|
|
+ backgroundColor: '#fff',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {placeholder && <option value="">{placeholder}</option>}
|
|
|
+ {options.map((opt) => (
|
|
|
+ <option key={opt.value} value={opt.value}>
|
|
|
+ {opt.label}
|
|
|
+ </option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// DatePicker component (simplified)
|
|
|
+export function DatePicker({ element }: ComponentRenderProps) {
|
|
|
+ const { label, valuePath } = element.props as {
|
|
|
+ label: string;
|
|
|
+ valuePath: string;
|
|
|
+ };
|
|
|
+
|
|
|
+ const { data, set } = useData();
|
|
|
+ const value = getByPath(data, valuePath) as string | undefined;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
|
|
+ <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
|
|
|
+ {label}
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ type="date"
|
|
|
+ value={value ?? ''}
|
|
|
+ onChange={(e) => set(valuePath, e.target.value)}
|
|
|
+ style={{
|
|
|
+ padding: '8px 12px',
|
|
|
+ borderRadius: '6px',
|
|
|
+ border: '1px solid #d1d5db',
|
|
|
+ fontSize: '14px',
|
|
|
+ outline: 'none',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// Empty component
|
|
|
+export function Empty({ element }: ComponentRenderProps) {
|
|
|
+ const { icon, title, description } = element.props as {
|
|
|
+ icon?: string | null;
|
|
|
+ title: string;
|
|
|
+ description?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div style={{ textAlign: 'center', padding: '40px 20px' }}>
|
|
|
+ {icon && <div style={{ fontSize: '48px', marginBottom: '16px' }}>{icon}</div>}
|
|
|
+ <h3 style={{ margin: '0 0 8px', fontSize: '16px', fontWeight: 600, color: '#111827' }}>
|
|
|
+ {title}
|
|
|
+ </h3>
|
|
|
+ {description && (
|
|
|
+ <p style={{ margin: 0, fontSize: '14px', color: '#6b7280' }}>{description}</p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// List component
|
|
|
+export function List({ element, children }: ComponentRenderProps) {
|
|
|
+ const { dataPath } = element.props as {
|
|
|
+ dataPath: string;
|
|
|
+ itemKey?: string | null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const { data } = useData();
|
|
|
+ const listData = getByPath(data, dataPath) as Array<unknown> | undefined;
|
|
|
+
|
|
|
+ if (!listData || !Array.isArray(listData)) {
|
|
|
+ return <div style={{ color: '#6b7280' }}>No items</div>;
|
|
|
+ }
|
|
|
+
|
|
|
+ // For now, just render children
|
|
|
+ // In a full implementation, children would be cloned for each item
|
|
|
+ return <div>{children}</div>;
|
|
|
+}
|
|
|
+
|
|
|
+// Export all components as a registry
|
|
|
+export const componentRegistry = {
|
|
|
+ Card,
|
|
|
+ Heading,
|
|
|
+ Text,
|
|
|
+ Metric,
|
|
|
+ Chart,
|
|
|
+ Table,
|
|
|
+ Button,
|
|
|
+ Alert,
|
|
|
+ Grid,
|
|
|
+ Stack,
|
|
|
+ Divider,
|
|
|
+ Badge,
|
|
|
+ TextField,
|
|
|
+ Select,
|
|
|
+ DatePicker,
|
|
|
+ Empty,
|
|
|
+ List,
|
|
|
+};
|