373 lines
10 KiB
TypeScript
373 lines
10 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* DynamicConfigPanel
|
||
|
|
*
|
||
|
|
* JSON Schema 기반으로 동적으로 설정 UI를 생성하는 패널
|
||
|
|
* 모든 Unified 컴포넌트의 설정을 단일 컴포넌트로 처리
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React, { useCallback, useMemo } from "react";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { Switch } from "@/components/ui/switch";
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
|
|
import { Slider } from "@/components/ui/slider";
|
||
|
|
import { Textarea } from "@/components/ui/textarea";
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||
|
|
import { ChevronDown } from "lucide-react";
|
||
|
|
import { JSONSchemaProperty, UnifiedConfigSchema } from "@/types/unified-components";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
interface DynamicConfigPanelProps {
|
||
|
|
schema: UnifiedConfigSchema;
|
||
|
|
config: Record<string, unknown>;
|
||
|
|
onChange: (key: string, value: unknown) => void;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 개별 스키마 속성을 렌더링하는 컴포넌트
|
||
|
|
*/
|
||
|
|
function SchemaField({
|
||
|
|
name,
|
||
|
|
property,
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
path = [],
|
||
|
|
}: {
|
||
|
|
name: string;
|
||
|
|
property: JSONSchemaProperty;
|
||
|
|
value: unknown;
|
||
|
|
onChange: (key: string, value: unknown) => void;
|
||
|
|
path?: string[];
|
||
|
|
}) {
|
||
|
|
const fieldPath = [...path, name].join(".");
|
||
|
|
|
||
|
|
// 값 변경 핸들러
|
||
|
|
const handleChange = useCallback(
|
||
|
|
(newValue: unknown) => {
|
||
|
|
onChange(fieldPath, newValue);
|
||
|
|
},
|
||
|
|
[fieldPath, onChange]
|
||
|
|
);
|
||
|
|
|
||
|
|
// 타입에 따른 컴포넌트 렌더링
|
||
|
|
const renderField = () => {
|
||
|
|
// enum이 있으면 Select 렌더링
|
||
|
|
if (property.enum && property.enum.length > 0) {
|
||
|
|
return (
|
||
|
|
<Select
|
||
|
|
value={String(value ?? property.default ?? "")}
|
||
|
|
onValueChange={handleChange}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-8 text-xs">
|
||
|
|
<SelectValue placeholder="선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{property.enum.map((option) => (
|
||
|
|
<SelectItem key={option} value={option} className="text-xs">
|
||
|
|
{option}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 타입별 렌더링
|
||
|
|
switch (property.type) {
|
||
|
|
case "string":
|
||
|
|
return (
|
||
|
|
<Input
|
||
|
|
type="text"
|
||
|
|
value={String(value ?? property.default ?? "")}
|
||
|
|
onChange={(e) => handleChange(e.target.value)}
|
||
|
|
placeholder={property.description}
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "number":
|
||
|
|
return (
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
value={value !== undefined && value !== null ? Number(value) : ""}
|
||
|
|
onChange={(e) => handleChange(e.target.value ? Number(e.target.value) : undefined)}
|
||
|
|
placeholder={property.description}
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "boolean":
|
||
|
|
return (
|
||
|
|
<Switch
|
||
|
|
checked={Boolean(value ?? property.default ?? false)}
|
||
|
|
onCheckedChange={handleChange}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "array":
|
||
|
|
// 배열은 간단한 텍스트 입력으로 처리 (쉼표 구분)
|
||
|
|
return (
|
||
|
|
<Textarea
|
||
|
|
value={Array.isArray(value) ? value.join(", ") : ""}
|
||
|
|
onChange={(e) => {
|
||
|
|
const arr = e.target.value.split(",").map((s) => s.trim()).filter(Boolean);
|
||
|
|
handleChange(arr);
|
||
|
|
}}
|
||
|
|
placeholder="쉼표로 구분하여 입력"
|
||
|
|
className="text-xs min-h-[60px]"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "object":
|
||
|
|
// 중첩 객체는 별도 섹션으로 렌더링
|
||
|
|
if (property.properties) {
|
||
|
|
return (
|
||
|
|
<div className="mt-2 pl-4 border-l-2 border-muted space-y-3">
|
||
|
|
{Object.entries(property.properties).map(([subName, subProp]) => (
|
||
|
|
<SchemaField
|
||
|
|
key={subName}
|
||
|
|
name={subName}
|
||
|
|
property={subProp}
|
||
|
|
value={(value as Record<string, unknown>)?.[subName]}
|
||
|
|
onChange={onChange}
|
||
|
|
path={[...path, name]}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
|
||
|
|
default:
|
||
|
|
return (
|
||
|
|
<Input
|
||
|
|
type="text"
|
||
|
|
value={String(value ?? "")}
|
||
|
|
onChange={(e) => handleChange(e.target.value)}
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<Label className="text-xs font-medium">
|
||
|
|
{property.title || name}
|
||
|
|
</Label>
|
||
|
|
{property.type === "boolean" && renderField()}
|
||
|
|
</div>
|
||
|
|
{property.description && (
|
||
|
|
<p className="text-[10px] text-muted-foreground">{property.description}</p>
|
||
|
|
)}
|
||
|
|
{property.type !== "boolean" && renderField()}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 메인 DynamicConfigPanel 컴포넌트
|
||
|
|
*/
|
||
|
|
export function DynamicConfigPanel({
|
||
|
|
schema,
|
||
|
|
config,
|
||
|
|
onChange,
|
||
|
|
className,
|
||
|
|
}: DynamicConfigPanelProps) {
|
||
|
|
// 속성들을 카테고리별로 그룹화
|
||
|
|
const groupedProperties = useMemo(() => {
|
||
|
|
const groups: Record<string, Array<[string, JSONSchemaProperty]>> = {
|
||
|
|
기본: [],
|
||
|
|
고급: [],
|
||
|
|
스타일: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
Object.entries(schema.properties).forEach(([name, property]) => {
|
||
|
|
// 이름 기반으로 그룹 분류
|
||
|
|
if (name.includes("style") || name.includes("Style")) {
|
||
|
|
groups["스타일"].push([name, property]);
|
||
|
|
} else if (
|
||
|
|
name.includes("cascade") ||
|
||
|
|
name.includes("mutual") ||
|
||
|
|
name.includes("conditional") ||
|
||
|
|
name.includes("autoFill")
|
||
|
|
) {
|
||
|
|
groups["고급"].push([name, property]);
|
||
|
|
} else {
|
||
|
|
groups["기본"].push([name, property]);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return groups;
|
||
|
|
}, [schema.properties]);
|
||
|
|
|
||
|
|
// 값 변경 핸들러 (중첩 경로 지원)
|
||
|
|
const handleChange = useCallback(
|
||
|
|
(path: string, value: unknown) => {
|
||
|
|
onChange(path, value);
|
||
|
|
},
|
||
|
|
[onChange]
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn("space-y-4", className)}>
|
||
|
|
{Object.entries(groupedProperties).map(
|
||
|
|
([groupName, properties]) =>
|
||
|
|
properties.length > 0 && (
|
||
|
|
<Collapsible key={groupName} defaultOpen={groupName === "기본"}>
|
||
|
|
<Card>
|
||
|
|
<CollapsibleTrigger asChild>
|
||
|
|
<CardHeader className="cursor-pointer py-3 px-4">
|
||
|
|
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||
|
|
{groupName} 설정
|
||
|
|
<ChevronDown className="h-4 w-4" />
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
</CollapsibleTrigger>
|
||
|
|
<CollapsibleContent>
|
||
|
|
<CardContent className="pt-0 space-y-4">
|
||
|
|
{properties.map(([name, property]) => (
|
||
|
|
<SchemaField
|
||
|
|
key={name}
|
||
|
|
name={name}
|
||
|
|
property={property}
|
||
|
|
value={config[name]}
|
||
|
|
onChange={handleChange}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</CardContent>
|
||
|
|
</CollapsibleContent>
|
||
|
|
</Card>
|
||
|
|
</Collapsible>
|
||
|
|
)
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 기본 스키마들 (자주 사용되는 설정)
|
||
|
|
*/
|
||
|
|
export const COMMON_SCHEMAS = {
|
||
|
|
// UnifiedInput 기본 스키마
|
||
|
|
UnifiedInput: {
|
||
|
|
type: "object" as const,
|
||
|
|
properties: {
|
||
|
|
type: {
|
||
|
|
type: "string" as const,
|
||
|
|
enum: ["text", "number", "password", "slider", "color", "button"],
|
||
|
|
default: "text",
|
||
|
|
title: "입력 타입",
|
||
|
|
},
|
||
|
|
format: {
|
||
|
|
type: "string" as const,
|
||
|
|
enum: ["none", "email", "tel", "url", "currency", "biz_no"],
|
||
|
|
default: "none",
|
||
|
|
title: "형식",
|
||
|
|
},
|
||
|
|
placeholder: {
|
||
|
|
type: "string" as const,
|
||
|
|
title: "플레이스홀더",
|
||
|
|
},
|
||
|
|
min: {
|
||
|
|
type: "number" as const,
|
||
|
|
title: "최소값",
|
||
|
|
description: "숫자 타입 전용",
|
||
|
|
},
|
||
|
|
max: {
|
||
|
|
type: "number" as const,
|
||
|
|
title: "최대값",
|
||
|
|
description: "숫자 타입 전용",
|
||
|
|
},
|
||
|
|
step: {
|
||
|
|
type: "number" as const,
|
||
|
|
title: "증가 단위",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
|
||
|
|
// UnifiedSelect 기본 스키마
|
||
|
|
UnifiedSelect: {
|
||
|
|
type: "object" as const,
|
||
|
|
properties: {
|
||
|
|
mode: {
|
||
|
|
type: "string" as const,
|
||
|
|
enum: ["dropdown", "radio", "check", "tag", "toggle", "swap"],
|
||
|
|
default: "dropdown",
|
||
|
|
title: "표시 모드",
|
||
|
|
},
|
||
|
|
source: {
|
||
|
|
type: "string" as const,
|
||
|
|
enum: ["static", "code", "db", "api", "entity"],
|
||
|
|
default: "static",
|
||
|
|
title: "데이터 소스",
|
||
|
|
},
|
||
|
|
codeGroup: {
|
||
|
|
type: "string" as const,
|
||
|
|
title: "코드 그룹",
|
||
|
|
description: "source가 code일 때 사용",
|
||
|
|
},
|
||
|
|
searchable: {
|
||
|
|
type: "boolean" as const,
|
||
|
|
default: false,
|
||
|
|
title: "검색 가능",
|
||
|
|
},
|
||
|
|
multiple: {
|
||
|
|
type: "boolean" as const,
|
||
|
|
default: false,
|
||
|
|
title: "다중 선택",
|
||
|
|
},
|
||
|
|
maxSelect: {
|
||
|
|
type: "number" as const,
|
||
|
|
title: "최대 선택 수",
|
||
|
|
},
|
||
|
|
cascading: {
|
||
|
|
type: "object" as const,
|
||
|
|
title: "연쇄 관계",
|
||
|
|
properties: {
|
||
|
|
parentField: { type: "string" as const, title: "부모 필드" },
|
||
|
|
filterColumn: { type: "string" as const, title: "필터 컬럼" },
|
||
|
|
clearOnChange: { type: "boolean" as const, default: true, title: "부모 변경시 초기화" },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
|
||
|
|
// UnifiedDate 기본 스키마
|
||
|
|
UnifiedDate: {
|
||
|
|
type: "object" as const,
|
||
|
|
properties: {
|
||
|
|
type: {
|
||
|
|
type: "string" as const,
|
||
|
|
enum: ["date", "time", "datetime"],
|
||
|
|
default: "date",
|
||
|
|
title: "타입",
|
||
|
|
},
|
||
|
|
format: {
|
||
|
|
type: "string" as const,
|
||
|
|
default: "YYYY-MM-DD",
|
||
|
|
title: "날짜 형식",
|
||
|
|
},
|
||
|
|
range: {
|
||
|
|
type: "boolean" as const,
|
||
|
|
default: false,
|
||
|
|
title: "범위 선택",
|
||
|
|
},
|
||
|
|
showToday: {
|
||
|
|
type: "boolean" as const,
|
||
|
|
default: true,
|
||
|
|
title: "오늘 버튼",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
} satisfies Record<string, UnifiedConfigSchema>;
|
||
|
|
|
||
|
|
export default DynamicConfigPanel;
|
||
|
|
|
||
|
|
|