366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useEffect, useState } from "react";
|
||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Loader2 } from "lucide-react";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import { FieldRenderer } from "../Field/FieldRenderer";
|
||
|
|
import { DataViewRenderer } from "../DataView/DataViewRenderer";
|
||
|
|
import { ActionRenderer } from "../Action/ActionRenderer";
|
||
|
|
import { ReactiveBindingEngine } from "../bindings/ReactiveBindingEngine";
|
||
|
|
import {
|
||
|
|
getFieldConfig,
|
||
|
|
getTableColumns,
|
||
|
|
saveLayout,
|
||
|
|
getLayout,
|
||
|
|
saveBindings,
|
||
|
|
getBindings,
|
||
|
|
type FieldConfigResponse,
|
||
|
|
type TableColumnsResponse,
|
||
|
|
} from "@/lib/api/metaComponent";
|
||
|
|
import type { FieldConfig } from "../Field/fieldTypes";
|
||
|
|
import type { DataViewConfig } from "../DataView/dataViewTypes";
|
||
|
|
import type { ActionConfig } from "../Action/actionTypes";
|
||
|
|
import type { ReactiveBinding } from "../bindings/bindingTypes";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 메타 컴포넌트 V3 사용 예시
|
||
|
|
*
|
||
|
|
* Phase A 구현 범위:
|
||
|
|
* 1. Field 자동 생성 (테이블 컬럼 기반)
|
||
|
|
* 2. DataView 렌더링 (테이블/카드 뷰)
|
||
|
|
* 3. Action 버튼 + 파이프라인
|
||
|
|
* 4. Reactive Binding 설정 및 실행
|
||
|
|
*/
|
||
|
|
export function MetaComponentExample() {
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [fieldConfigs, setFieldConfigs] = useState<FieldConfig[]>([]);
|
||
|
|
const [dataViewConfig, setDataViewConfig] = useState<DataViewConfig | null>(null);
|
||
|
|
const [bindingEngine] = useState(() => new ReactiveBindingEngine());
|
||
|
|
|
||
|
|
// 예시: user_info 테이블 기반 화면 자동 생성
|
||
|
|
useEffect(() => {
|
||
|
|
loadMetaComponents();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 백엔드 API 호출하여 메타 컴포넌트 자동 생성
|
||
|
|
*/
|
||
|
|
async function loadMetaComponents() {
|
||
|
|
try {
|
||
|
|
setLoading(true);
|
||
|
|
|
||
|
|
// 1. 테이블 컬럼 목록 조회 (webType + FK 관계 자동 감지)
|
||
|
|
const columnsResponse = await getTableColumns("user_info");
|
||
|
|
|
||
|
|
if (!columnsResponse.success || !columnsResponse.data) {
|
||
|
|
throw new Error("테이블 컬럼 조회 실패");
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. 각 컬럼별 Field Config 자동 생성
|
||
|
|
const configs: FieldConfig[] = [];
|
||
|
|
for (const column of columnsResponse.data.columns) {
|
||
|
|
const configResponse = await getFieldConfig("user_info", column.columnName);
|
||
|
|
|
||
|
|
if (configResponse.success && configResponse.data) {
|
||
|
|
configs.push({
|
||
|
|
id: `field_${column.columnName}`,
|
||
|
|
type: "meta-field",
|
||
|
|
webType: configResponse.data.webType,
|
||
|
|
label: configResponse.data.label,
|
||
|
|
binding: column.columnName,
|
||
|
|
placeholder: configResponse.data.placeholder,
|
||
|
|
defaultValue: configResponse.data.defaultValue,
|
||
|
|
required: configResponse.data.required,
|
||
|
|
maxLength: configResponse.data.maxLength,
|
||
|
|
validation: configResponse.data.validation,
|
||
|
|
options: configResponse.data.options,
|
||
|
|
join: configResponse.data.join,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setFieldConfigs(configs);
|
||
|
|
|
||
|
|
// 3. DataView 설정 (테이블 뷰)
|
||
|
|
setDataViewConfig({
|
||
|
|
id: "dataview_user_list",
|
||
|
|
type: "meta-dataview",
|
||
|
|
viewMode: "table",
|
||
|
|
tableName: "user_info",
|
||
|
|
columns: columnsResponse.data.columns.map(col => ({
|
||
|
|
field: col.columnName,
|
||
|
|
header: col.columnComment || col.columnName,
|
||
|
|
webType: col.webType,
|
||
|
|
})),
|
||
|
|
pagination: {
|
||
|
|
enabled: true,
|
||
|
|
pageSize: 20,
|
||
|
|
pageSizeOptions: [10, 20, 50, 100],
|
||
|
|
},
|
||
|
|
actions: {
|
||
|
|
add: {
|
||
|
|
enabled: true,
|
||
|
|
label: "등록",
|
||
|
|
icon: "plus",
|
||
|
|
},
|
||
|
|
edit: {
|
||
|
|
enabled: true,
|
||
|
|
label: "수정",
|
||
|
|
icon: "pencil",
|
||
|
|
},
|
||
|
|
delete: {
|
||
|
|
enabled: true,
|
||
|
|
label: "삭제",
|
||
|
|
icon: "trash",
|
||
|
|
confirmMessage: "정말로 삭제하시겠습니까?",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// 4. Reactive Binding 설정 (예시: 부서 선택 → 사용자 목록 필터링)
|
||
|
|
const bindings: ReactiveBinding[] = [
|
||
|
|
{
|
||
|
|
id: "binding_dept_filter",
|
||
|
|
sourceComponent: "field_dept_code",
|
||
|
|
sourceEvent: "onChange",
|
||
|
|
targetComponent: "dataview_user_list",
|
||
|
|
targetAction: "filter",
|
||
|
|
condition: {
|
||
|
|
type: "expression",
|
||
|
|
expression: "source.value !== ''",
|
||
|
|
},
|
||
|
|
transform: {
|
||
|
|
config: {
|
||
|
|
filterField: "dept_code",
|
||
|
|
filterOperator: "equals",
|
||
|
|
filterValue: "{{source.value}}",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
priority: 10,
|
||
|
|
enabled: true,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
bindingEngine.setBindings(bindings);
|
||
|
|
|
||
|
|
toast.success("메타 컴포넌트 로드 완료!");
|
||
|
|
} catch (error: any) {
|
||
|
|
console.error("메타 컴포넌트 로드 실패:", error);
|
||
|
|
toast.error(error.message || "메타 컴포넌트 로드 실패");
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 레이아웃 저장 (screen_layouts_v3 테이블에 version "3.0"로 저장)
|
||
|
|
*/
|
||
|
|
async function handleSaveLayout() {
|
||
|
|
try {
|
||
|
|
const layoutData = {
|
||
|
|
version: "3.0" as const,
|
||
|
|
screenId: 123, // 실제 화면 ID
|
||
|
|
layerId: 1,
|
||
|
|
components: [
|
||
|
|
...fieldConfigs,
|
||
|
|
...(dataViewConfig ? [dataViewConfig] : []),
|
||
|
|
],
|
||
|
|
layers: [
|
||
|
|
{ id: 1, name: "기본", visible: true, order: 1 },
|
||
|
|
],
|
||
|
|
metadata: {
|
||
|
|
lastModified: new Date().toISOString(),
|
||
|
|
description: "사용자 관리 화면 (메타 컴포넌트 V3)",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const response = await saveLayout(layoutData);
|
||
|
|
|
||
|
|
if (response.success) {
|
||
|
|
toast.success("레이아웃 저장 완료!");
|
||
|
|
} else {
|
||
|
|
throw new Error(response.error || "저장 실패");
|
||
|
|
}
|
||
|
|
} catch (error: any) {
|
||
|
|
console.error("레이아웃 저장 실패:", error);
|
||
|
|
toast.error(error.message || "레이아웃 저장 실패");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reactive Binding 저장
|
||
|
|
*/
|
||
|
|
async function handleSaveBindings() {
|
||
|
|
try {
|
||
|
|
const bindings = bindingEngine.getBindings();
|
||
|
|
const response = await saveBindings({
|
||
|
|
screenId: 123,
|
||
|
|
bindings,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.success) {
|
||
|
|
toast.success("바인딩 저장 완료!");
|
||
|
|
} else {
|
||
|
|
throw new Error(response.error || "저장 실패");
|
||
|
|
}
|
||
|
|
} catch (error: any) {
|
||
|
|
console.error("바인딩 저장 실패:", error);
|
||
|
|
toast.error(error.message || "바인딩 저장 실패");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-screen items-center justify-center">
|
||
|
|
<div className="flex flex-col items-center gap-4">
|
||
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||
|
|
<p className="text-sm text-muted-foreground">메타 컴포넌트 로딩 중...</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||
|
|
{/* 헤더 */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-2xl sm:text-3xl">메타 컴포넌트 V3 예시</CardTitle>
|
||
|
|
<CardDescription className="text-sm sm:text-base">
|
||
|
|
테이블 컬럼 기반 자동 화면 생성 (Phase A)
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="flex flex-wrap gap-2">
|
||
|
|
<Button onClick={handleSaveLayout} variant="default">
|
||
|
|
레이아웃 저장
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleSaveBindings} variant="outline">
|
||
|
|
바인딩 저장
|
||
|
|
</Button>
|
||
|
|
<Button onClick={loadMetaComponents} variant="ghost">
|
||
|
|
새로고침
|
||
|
|
</Button>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Field 컴포넌트들 (자동 생성) */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-lg sm:text-xl">필드 목록</CardTitle>
|
||
|
|
<CardDescription className="text-xs sm:text-sm">
|
||
|
|
table_type_columns의 webType 기반 자동 생성
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||
|
|
{fieldConfigs.map((config) => (
|
||
|
|
<div key={config.id} className="space-y-2">
|
||
|
|
<FieldRenderer
|
||
|
|
config={config}
|
||
|
|
value={config.defaultValue}
|
||
|
|
onChange={(value) => {
|
||
|
|
console.log(`${config.binding} 변경:`, value);
|
||
|
|
// Reactive Binding 실행
|
||
|
|
bindingEngine.emitEvent(config.id, "onChange", { value });
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* DataView 컴포넌트 (자동 생성) */}
|
||
|
|
{dataViewConfig && (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-lg sm:text-xl">데이터 뷰</CardTitle>
|
||
|
|
<CardDescription className="text-xs sm:text-sm">
|
||
|
|
테이블/카드/리스트 뷰 전환 가능
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<DataViewRenderer
|
||
|
|
config={dataViewConfig}
|
||
|
|
data={[
|
||
|
|
{ user_id: "admin", user_name: "관리자", dept_code: "IT" },
|
||
|
|
{ user_id: "user1", user_name: "홍길동", dept_code: "HR" },
|
||
|
|
]}
|
||
|
|
onActionClick={(action, rowData) => {
|
||
|
|
console.log("액션 클릭:", action, rowData);
|
||
|
|
toast.info(`${action} 액션 실행`);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Action 컴포넌트 예시 */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-lg sm:text-xl">액션 버튼</CardTitle>
|
||
|
|
<CardDescription className="text-xs sm:text-sm">
|
||
|
|
버튼 + steps 파이프라인 (Phase B에서 완전 구현 예정)
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="flex flex-wrap gap-2">
|
||
|
|
<ActionRenderer
|
||
|
|
config={{
|
||
|
|
id: "action_save",
|
||
|
|
type: "meta-action",
|
||
|
|
label: "저장",
|
||
|
|
buttonType: "primary",
|
||
|
|
icon: "save",
|
||
|
|
confirmDialog: {
|
||
|
|
enabled: true,
|
||
|
|
title: "저장 확인",
|
||
|
|
message: "변경 사항을 저장하시겠습니까?",
|
||
|
|
},
|
||
|
|
steps: [
|
||
|
|
{
|
||
|
|
id: "step1",
|
||
|
|
type: "validate",
|
||
|
|
name: "유효성 검증",
|
||
|
|
config: { fields: ["user_name", "dept_code"] },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "step2",
|
||
|
|
type: "api",
|
||
|
|
name: "API 호출",
|
||
|
|
config: {
|
||
|
|
endpoint: "/api/users",
|
||
|
|
method: "POST",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}}
|
||
|
|
onClick={async () => {
|
||
|
|
console.log("저장 버튼 클릭");
|
||
|
|
toast.success("저장 완료!");
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<ActionRenderer
|
||
|
|
config={{
|
||
|
|
id: "action_delete",
|
||
|
|
type: "meta-action",
|
||
|
|
label: "삭제",
|
||
|
|
buttonType: "danger",
|
||
|
|
icon: "trash",
|
||
|
|
confirmDialog: {
|
||
|
|
enabled: true,
|
||
|
|
title: "삭제 확인",
|
||
|
|
message: "정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||
|
|
},
|
||
|
|
}}
|
||
|
|
onClick={async () => {
|
||
|
|
console.log("삭제 버튼 클릭");
|
||
|
|
toast.error("삭제 완료!");
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|