ERP-node/frontend/components/unified/DynamicConfigPanel.tsx

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;