feat: enhance component configuration and rendering
- Updated the RealtimePreviewDynamic component to display selected component information more clearly. - Added dynamic field type labels in the RealtimePreviewDynamic component for better user understanding. - Introduced a table refresh counter in the ScreenDesigner component to handle table column updates effectively. - Improved the V2PropertiesPanel and V2SelectConfigPanel to support additional properties and enhance usability. - Refactored the DynamicComponentRenderer to better handle field types and improve component configuration merging. Made-with: Cursor
This commit is contained in:
parent
83aa8f3250
commit
80be7c5a76
|
|
@ -771,7 +771,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */}
|
||||
{/* 선택된 컴포넌트 정보 표시 */}
|
||||
{isSelected && (
|
||||
<div className="bg-primary text-primary-foreground absolute -top-7 right-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm">
|
||||
{type === "widget" && (
|
||||
|
|
@ -782,7 +782,18 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
)}
|
||||
{type !== "widget" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{component.componentConfig?.type || type}</span>
|
||||
<span>{(() => {
|
||||
const ft = (component as any).componentConfig?.fieldType;
|
||||
if (ft) {
|
||||
const labels: Record<string, string> = {
|
||||
text: "텍스트", number: "숫자", textarea: "여러줄",
|
||||
select: "셀렉트", category: "카테고리", entity: "엔티티",
|
||||
numbering: "채번",
|
||||
};
|
||||
return labels[ft] || ft;
|
||||
}
|
||||
return (component as any).componentConfig?.type || componentType || type;
|
||||
})()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -475,6 +475,7 @@ export default function ScreenDesigner({
|
|||
|
||||
// 테이블 데이터
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [tableRefreshCounter, setTableRefreshCounter] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 🆕 검색어로 필터링된 테이블 목록
|
||||
|
|
@ -1434,8 +1435,16 @@ export default function ScreenDesigner({
|
|||
selectedScreen?.restApiConnectionId,
|
||||
selectedScreen?.restApiEndpoint,
|
||||
selectedScreen?.restApiJsonPath,
|
||||
tableRefreshCounter,
|
||||
]);
|
||||
|
||||
// 필드 타입 변경 시 테이블 컬럼 정보 갱신 (화면 디자이너에서 input_type 변경 반영)
|
||||
useEffect(() => {
|
||||
const handler = () => setTableRefreshCounter((c) => c + 1);
|
||||
window.addEventListener("table-columns-refresh", handler);
|
||||
return () => window.removeEventListener("table-columns-refresh", handler);
|
||||
}, []);
|
||||
|
||||
// 테이블 선택 핸들러 - 사이드바에서 테이블 선택 시 호출
|
||||
const handleTableSelect = useCallback(
|
||||
async (tableName: string) => {
|
||||
|
|
|
|||
|
|
@ -214,15 +214,18 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={handlePanelConfigChange}
|
||||
onConfigChange={handlePanelConfigChange} // 🔧 autocomplete-search-input 등 일부 컴포넌트용
|
||||
tables={tables} // 테이블 정보 전달
|
||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (채번 규칙 등)
|
||||
// 🆕 집계 위젯 등에서 사용하는 컴포넌트 목록
|
||||
onConfigChange={handlePanelConfigChange}
|
||||
tables={tables}
|
||||
allTables={allTables}
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||
tableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||
columnName={(selectedComponent as any).columnName || currentConfig?.columnName || currentConfig?.fieldName}
|
||||
inputType={(selectedComponent as any).inputType || currentConfig?.inputType}
|
||||
componentType={componentType}
|
||||
tableColumns={currentTable?.columns || []}
|
||||
allComponents={allComponents}
|
||||
currentComponent={selectedComponent}
|
||||
menuObjid={menuObjid}
|
||||
screenComponents={allComponents.map((comp: any) => ({
|
||||
id: comp.id,
|
||||
componentType: comp.componentType || comp.type,
|
||||
|
|
@ -250,6 +253,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
return (
|
||||
<DynamicComponentConfigPanel
|
||||
componentId={fallbackId}
|
||||
componentType={componentType}
|
||||
config={selectedComponent.componentConfig || {}}
|
||||
onChange={handleDynamicConfigChange}
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,749 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* V2 통합 필드 설정 패널
|
||||
* 입력(text/number/textarea/numbering)과 선택(select/category/entity)을
|
||||
* 하나의 패널에서 전환할 수 있는 통합 설정 UI
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Type, Hash, AlignLeft, ListOrdered, List, Database, FolderTree,
|
||||
Settings, ChevronDown, Plus, Trash2, Loader2, Filter,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { AutoGenerationType } from "@/types/screen";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
import type { V2SelectFilter } from "@/types/v2-components";
|
||||
|
||||
// ─── 필드 유형 카드 정의 ───
|
||||
const FIELD_TYPE_CARDS = [
|
||||
{ value: "text", icon: Type, label: "텍스트", desc: "일반 텍스트 입력", group: "input" },
|
||||
{ value: "number", icon: Hash, label: "숫자", desc: "숫자만 입력", group: "input" },
|
||||
{ value: "textarea", icon: AlignLeft, label: "여러 줄", desc: "긴 텍스트 입력", group: "input" },
|
||||
{ value: "select", icon: List, label: "셀렉트", desc: "직접 옵션 선택", group: "select" },
|
||||
{ value: "category", icon: FolderTree, label: "카테고리", desc: "등록된 선택지", group: "select" },
|
||||
{ value: "entity", icon: Database, label: "테이블 참조", desc: "다른 테이블 참조", group: "select" },
|
||||
{ value: "numbering", icon: ListOrdered, label: "채번", desc: "자동 번호 생성", group: "input" },
|
||||
] as const;
|
||||
|
||||
type FieldType = typeof FIELD_TYPE_CARDS[number]["value"];
|
||||
|
||||
// 필터 조건 관련 상수
|
||||
const OPERATOR_OPTIONS = [
|
||||
{ value: "=", label: "같음 (=)" },
|
||||
{ value: "!=", label: "다름 (!=)" },
|
||||
{ value: ">", label: "초과 (>)" },
|
||||
{ value: "<", label: "미만 (<)" },
|
||||
{ value: ">=", label: "이상 (>=)" },
|
||||
{ value: "<=", label: "이하 (<=)" },
|
||||
{ value: "in", label: "포함 (IN)" },
|
||||
{ value: "notIn", label: "미포함 (NOT IN)" },
|
||||
{ value: "like", label: "유사 (LIKE)" },
|
||||
{ value: "isNull", label: "NULL" },
|
||||
{ value: "isNotNull", label: "NOT NULL" },
|
||||
] as const;
|
||||
|
||||
const VALUE_TYPE_OPTIONS = [
|
||||
{ value: "static", label: "고정값" },
|
||||
{ value: "field", label: "폼 필드 참조" },
|
||||
{ value: "user", label: "로그인 사용자" },
|
||||
] as const;
|
||||
|
||||
const USER_FIELD_OPTIONS = [
|
||||
{ value: "companyCode", label: "회사코드" },
|
||||
{ value: "userId", label: "사용자ID" },
|
||||
{ value: "deptCode", label: "부서코드" },
|
||||
{ value: "userName", label: "사용자명" },
|
||||
] as const;
|
||||
|
||||
interface ColumnOption {
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
}
|
||||
|
||||
interface CategoryValueOption {
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
}
|
||||
|
||||
// ─── 하위 호환: 기존 config에서 fieldType 추론 ───
|
||||
function resolveFieldType(config: Record<string, any>, componentType?: string): FieldType {
|
||||
if (config.fieldType) return config.fieldType as FieldType;
|
||||
|
||||
// v2-select 계열
|
||||
if (componentType === "v2-select" || config.source) {
|
||||
const source = config.source === "code" ? "category" : config.source;
|
||||
if (source === "entity") return "entity";
|
||||
if (source === "category") return "category";
|
||||
return "select";
|
||||
}
|
||||
|
||||
// v2-input 계열
|
||||
const it = config.inputType || config.type;
|
||||
if (it === "number") return "number";
|
||||
if (it === "textarea") return "textarea";
|
||||
if (it === "numbering") return "numbering";
|
||||
return "text";
|
||||
}
|
||||
|
||||
// ─── 필터 조건 서브 컴포넌트 ───
|
||||
const FilterConditionsSection: React.FC<{
|
||||
filters: V2SelectFilter[];
|
||||
columns: ColumnOption[];
|
||||
loadingColumns: boolean;
|
||||
targetTable: string;
|
||||
onFiltersChange: (filters: V2SelectFilter[]) => void;
|
||||
}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => {
|
||||
const addFilter = () => {
|
||||
onFiltersChange([...filters, { column: "", operator: "=", valueType: "static", value: "" }]);
|
||||
};
|
||||
const updateFilter = (index: number, patch: Partial<V2SelectFilter>) => {
|
||||
const updated = [...filters];
|
||||
updated[index] = { ...updated[index], ...patch };
|
||||
if (patch.valueType) {
|
||||
if (patch.valueType === "static") { updated[index].fieldRef = undefined; updated[index].userField = undefined; }
|
||||
else if (patch.valueType === "field") { updated[index].value = undefined; updated[index].userField = undefined; }
|
||||
else if (patch.valueType === "user") { updated[index].value = undefined; updated[index].fieldRef = undefined; }
|
||||
}
|
||||
if (patch.operator === "isNull" || patch.operator === "isNotNull") {
|
||||
updated[index].value = undefined; updated[index].fieldRef = undefined;
|
||||
updated[index].userField = undefined; updated[index].valueType = "static";
|
||||
}
|
||||
onFiltersChange(updated);
|
||||
};
|
||||
const removeFilter = (index: number) => onFiltersChange(filters.filter((_, i) => i !== index));
|
||||
const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">데이터 필터</span>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addFilter} className="h-6 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px]">{targetTable} 테이블에서 옵션을 불러올 때 적용할 조건</p>
|
||||
{loadingColumns && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" />컬럼 목록 로딩 중...</div>
|
||||
)}
|
||||
{filters.length === 0 && <p className="text-muted-foreground py-2 text-center text-xs">필터 조건이 없습니다</p>}
|
||||
<div className="space-y-2">
|
||||
{filters.map((filter, index) => (
|
||||
<div key={index} className="space-y-2 rounded-md border p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select value={filter.column || ""} onValueChange={(v) => updateFilter(index, { column: v })}>
|
||||
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
||||
<SelectContent>{columns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
|
||||
</Select>
|
||||
<Select value={filter.operator || "="} onValueChange={(v) => updateFilter(index, { operator: v as V2SelectFilter["operator"] })}>
|
||||
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{OPERATOR_OPTIONS.map((op) => (<SelectItem key={op.value} value={op.value}>{op.label}</SelectItem>))}</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => removeFilter(index)} className="text-destructive h-8 w-8 shrink-0 p-0"><Trash2 className="h-3 w-3" /></Button>
|
||||
</div>
|
||||
{needsValue(filter.operator) && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select value={filter.valueType || "static"} onValueChange={(v) => updateFilter(index, { valueType: v as V2SelectFilter["valueType"] })}>
|
||||
<SelectTrigger className="h-7 w-[100px] shrink-0 text-[11px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{VALUE_TYPE_OPTIONS.map((vt) => (<SelectItem key={vt.value} value={vt.value}>{vt.label}</SelectItem>))}</SelectContent>
|
||||
</Select>
|
||||
{(filter.valueType || "static") === "static" && (
|
||||
<Input value={String(filter.value ?? "")} onChange={(e) => updateFilter(index, { value: e.target.value })}
|
||||
placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"} className="h-7 flex-1 text-[11px]" />
|
||||
)}
|
||||
{filter.valueType === "field" && (
|
||||
<Input value={filter.fieldRef || ""} onChange={(e) => updateFilter(index, { fieldRef: e.target.value })} placeholder="참조할 필드명" className="h-7 flex-1 text-[11px]" />
|
||||
)}
|
||||
{filter.valueType === "user" && (
|
||||
<Select value={filter.userField || ""} onValueChange={(v) => updateFilter(index, { userField: v as V2SelectFilter["userField"] })}>
|
||||
<SelectTrigger className="h-7 flex-1 text-[11px]"><SelectValue placeholder="사용자 필드" /></SelectTrigger>
|
||||
<SelectContent>{USER_FIELD_OPTIONS.map((uf) => (<SelectItem key={uf.value} value={uf.value}>{uf.label}</SelectItem>))}</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 메인 컴포넌트 ───
|
||||
|
||||
interface V2FieldConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
tableName?: string;
|
||||
columnName?: string;
|
||||
tables?: Array<{ tableName: string; displayName?: string; tableComment?: string }>;
|
||||
menuObjid?: number;
|
||||
screenTableName?: string;
|
||||
inputType?: string;
|
||||
componentType?: string;
|
||||
}
|
||||
|
||||
export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
tableName,
|
||||
columnName,
|
||||
tables = [],
|
||||
screenTableName,
|
||||
inputType: metaInputType,
|
||||
componentType,
|
||||
}) => {
|
||||
const fieldType = resolveFieldType(config, componentType);
|
||||
const isSelectGroup = ["select", "category", "entity"].includes(fieldType);
|
||||
|
||||
// ─── 채번 관련 상태 (테이블 기반) ───
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(false);
|
||||
const numberingTableName = screenTableName || tableName;
|
||||
|
||||
// ─── 셀렉트 관련 상태 ───
|
||||
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
|
||||
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
||||
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
|
||||
const [loadingFilterColumns, setLoadingFilterColumns] = useState(false);
|
||||
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
// ─── 필드 타입 전환 핸들러 ───
|
||||
const handleFieldTypeChange = (newType: FieldType) => {
|
||||
const newIsSelect = ["select", "category", "entity"].includes(newType);
|
||||
const base: Record<string, any> = { ...config, fieldType: newType };
|
||||
|
||||
if (newIsSelect) {
|
||||
base.source = newType === "category" ? "category" : newType === "entity" ? "entity" : "static";
|
||||
delete base.inputType;
|
||||
} else {
|
||||
base.inputType = newType;
|
||||
// 선택형 -> 입력형 전환 시 source 잔류 제거 (안 지우면 '카테고리 값이 없습니다' 같은 오류 표시)
|
||||
delete base.source;
|
||||
}
|
||||
|
||||
if (newType === "numbering") {
|
||||
base.autoGeneration = {
|
||||
...config.autoGeneration,
|
||||
type: "numbering_rule" as AutoGenerationType,
|
||||
tableName: numberingTableName,
|
||||
};
|
||||
base.readonly = config.readonly ?? true;
|
||||
}
|
||||
|
||||
onChange(base);
|
||||
|
||||
// table_type_columns.input_type 동기화 (카테고리/엔티티 등 설정 가능하도록)
|
||||
const syncTableName = screenTableName || tableName;
|
||||
const syncColumnName = columnName || config.columnName || config.fieldName;
|
||||
if (syncTableName && syncColumnName) {
|
||||
apiClient.put(`/table-management/tables/${syncTableName}/columns/${syncColumnName}/input-type`, {
|
||||
inputType: newType,
|
||||
}).then(() => {
|
||||
// 왼쪽 테이블 패널의 컬럼 타입 뱃지 갱신
|
||||
window.dispatchEvent(new CustomEvent("table-columns-refresh"));
|
||||
}).catch(() => { /* 동기화 실패해도 화면 설정은 유지 */ });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 채번 규칙 로드 (테이블 기반) ───
|
||||
useEffect(() => {
|
||||
if (fieldType !== "numbering") return;
|
||||
if (!numberingTableName) { setNumberingRules([]); return; }
|
||||
const load = async () => {
|
||||
setLoadingRules(true);
|
||||
try {
|
||||
const resp = await getAvailableNumberingRulesForScreen(numberingTableName);
|
||||
if (resp.success && resp.data) setNumberingRules(resp.data);
|
||||
else setNumberingRules([]);
|
||||
} catch { setNumberingRules([]); } finally { setLoadingRules(false); }
|
||||
};
|
||||
load();
|
||||
}, [numberingTableName, fieldType]);
|
||||
|
||||
// ─── 엔티티 컬럼 로드 ───
|
||||
const loadEntityColumns = useCallback(async (tblName: string) => {
|
||||
if (!tblName) { setEntityColumns([]); return; }
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const resp = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`);
|
||||
const data = resp.data.data || resp.data;
|
||||
const cols = data.columns || data || [];
|
||||
setEntityColumns(cols.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name || col.name,
|
||||
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
||||
})));
|
||||
} catch { setEntityColumns([]); } finally { setLoadingColumns(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fieldType === "entity" && config.entityTable) loadEntityColumns(config.entityTable);
|
||||
}, [fieldType, config.entityTable, loadEntityColumns]);
|
||||
|
||||
// ─── 카테고리 값 로드 ───
|
||||
const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
|
||||
if (!catTable || !catColumn) { setCategoryValues([]); return; }
|
||||
setLoadingCategoryValues(true);
|
||||
try {
|
||||
const resp = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
|
||||
if (resp.data.success && resp.data.data) {
|
||||
const flattenTree = (items: any[], depth = 0): CategoryValueOption[] => {
|
||||
const result: CategoryValueOption[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ valueCode: item.valueCode, valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel });
|
||||
if (item.children?.length) result.push(...flattenTree(item.children, depth + 1));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
setCategoryValues(flattenTree(resp.data.data));
|
||||
}
|
||||
} catch { setCategoryValues([]); } finally { setLoadingCategoryValues(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fieldType === "category") {
|
||||
const catTable = config.categoryTable || tableName;
|
||||
const catColumn = config.categoryColumn || columnName;
|
||||
if (catTable && catColumn) loadCategoryValues(catTable, catColumn);
|
||||
}
|
||||
}, [fieldType, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]);
|
||||
|
||||
// ─── 필터 컬럼 로드 ───
|
||||
const filterTargetTable = useMemo(() => {
|
||||
if (fieldType === "entity") return config.entityTable;
|
||||
if (fieldType === "category") return config.categoryTable || tableName;
|
||||
return null;
|
||||
}, [fieldType, config.entityTable, config.categoryTable, tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterTargetTable) { setFilterColumns([]); return; }
|
||||
const load = async () => {
|
||||
setLoadingFilterColumns(true);
|
||||
try {
|
||||
const resp = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`);
|
||||
const data = resp.data.data || resp.data;
|
||||
const cols = data.columns || data || [];
|
||||
setFilterColumns(cols.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name || col.name,
|
||||
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
||||
})));
|
||||
} catch { setFilterColumns([]); } finally { setLoadingFilterColumns(false); }
|
||||
};
|
||||
load();
|
||||
}, [filterTargetTable]);
|
||||
|
||||
// ─── 옵션 관리 (select static) ───
|
||||
const options = config.options || [];
|
||||
const addOption = () => updateConfig("options", [...options, { value: "", label: "" }]);
|
||||
const updateOptionValue = (index: number, value: string) => {
|
||||
const newOpts = [...options];
|
||||
newOpts[index] = { ...newOpts[index], value, label: value };
|
||||
updateConfig("options", newOpts);
|
||||
};
|
||||
const removeOption = (index: number) => updateConfig("options", options.filter((_: any, i: number) => i !== index));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ═══ 1단계: 필드 유형 선택 ═══ */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">이 필드는 어떤 유형인가요?</p>
|
||||
<p className="text-[11px] text-muted-foreground">유형에 따라 입력 방식이 바뀌어요</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{FIELD_TYPE_CARDS.map((card) => {
|
||||
const Icon = card.icon;
|
||||
const isSelected = fieldType === card.value;
|
||||
return (
|
||||
<button
|
||||
key={card.value}
|
||||
type="button"
|
||||
onClick={() => handleFieldTypeChange(card.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[72px]",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-4 w-4 mb-1", isSelected ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className={cn("text-[11px] font-medium leading-tight", isSelected ? "text-primary" : "text-foreground")}>{card.label}</span>
|
||||
<span className="text-[9px] text-muted-foreground leading-tight mt-0.5">{card.desc}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ═══ 2단계: 유형별 상세 설정 ═══ */}
|
||||
|
||||
{/* ─── 텍스트/숫자/여러줄: 기본 설정 ─── */}
|
||||
{(fieldType === "text" || fieldType === "number" || fieldType === "textarea") && (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">안내 텍스트</span>
|
||||
<Input value={config.placeholder || ""} onChange={(e) => updateConfig("placeholder", e.target.value)} placeholder="입력 안내" className="h-7 w-[160px] text-xs" />
|
||||
</div>
|
||||
|
||||
{fieldType === "text" && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">입력 형식</span>
|
||||
<Select value={config.format || "none"} onValueChange={(v) => updateConfig("format", v)}>
|
||||
<SelectTrigger className="h-7 w-[160px] text-xs"><SelectValue placeholder="형식 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">제한 없음</SelectItem>
|
||||
<SelectItem value="email">이메일</SelectItem>
|
||||
<SelectItem value="tel">전화번호</SelectItem>
|
||||
<SelectItem value="url">URL</SelectItem>
|
||||
<SelectItem value="currency">통화</SelectItem>
|
||||
<SelectItem value="biz_no">사업자번호</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldType === "number" && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<p className="text-xs text-muted-foreground">값 범위</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-[10px] text-muted-foreground">최소값</Label>
|
||||
<Input type="number" value={config.min ?? ""} onChange={(e) => updateConfig("min", e.target.value ? Number(e.target.value) : undefined)} placeholder="0" className="h-7 text-xs" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-[10px] text-muted-foreground">최대값</Label>
|
||||
<Input type="number" value={config.max ?? ""} onChange={(e) => updateConfig("max", e.target.value ? Number(e.target.value) : undefined)} placeholder="100" className="h-7 text-xs" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-[10px] text-muted-foreground">단계</Label>
|
||||
<Input type="number" value={config.step ?? ""} onChange={(e) => updateConfig("step", e.target.value ? Number(e.target.value) : undefined)} placeholder="1" className="h-7 text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldType === "textarea" && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">줄 수</span>
|
||||
<Input type="number" value={config.rows || 3} onChange={(e) => updateConfig("rows", parseInt(e.target.value) || 3)} min={2} max={20} className="h-7 w-[160px] text-xs" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 셀렉트 (직접 입력): 옵션 관리 ─── */}
|
||||
{fieldType === "select" && (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">옵션 목록</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addOption} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />추가
|
||||
</Button>
|
||||
</div>
|
||||
{options.length > 0 ? (
|
||||
<div className="max-h-40 space-y-1.5 overflow-y-auto">
|
||||
{options.map((option: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input value={option.value || ""} onChange={(e) => updateOptionValue(index, e.target.value)} placeholder={`옵션 ${index + 1}`} className="h-8 flex-1 text-sm" />
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeOption(index)} className="text-destructive h-8 w-8 shrink-0"><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<List className="mx-auto mb-2 h-8 w-8 opacity-30" />
|
||||
<p className="text-sm">아직 옵션이 없어요</p>
|
||||
<p className="text-xs">위의 추가 버튼으로 옵션을 만들어보세요</p>
|
||||
</div>
|
||||
)}
|
||||
{options.length > 0 && (
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">기본 선택값</span>
|
||||
<Select value={config.defaultValue || "_none_"} onValueChange={(v) => updateConfig("defaultValue", v === "_none_" ? "" : v)}>
|
||||
<SelectTrigger className="h-8 w-[160px] text-sm"><SelectValue placeholder="선택 안함" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none_">선택 안함</SelectItem>
|
||||
{options.map((opt: any, i: number) => (<SelectItem key={`d-${i}`} value={opt.value || `_idx_${i}`}>{opt.label || opt.value || `옵션 ${i + 1}`}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 카테고리 ─── */}
|
||||
{fieldType === "category" && (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderTree className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">카테고리</span>
|
||||
</div>
|
||||
{config.source === "code" && config.codeGroup && (
|
||||
<div className="rounded-md border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground">코드 그룹</p>
|
||||
<p className="mt-0.5 text-sm font-medium">{config.codeGroup}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border bg-background p-3">
|
||||
<div className="flex gap-6">
|
||||
<div><p className="text-xs text-muted-foreground">테이블</p><p className="text-sm font-medium">{config.categoryTable || tableName || "-"}</p></div>
|
||||
<div><p className="text-xs text-muted-foreground">컬럼</p><p className="text-sm font-medium">{config.categoryColumn || columnName || "-"}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
{loadingCategoryValues && <div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" />카테고리 값 로딩 중...</div>}
|
||||
{categoryValues.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">{categoryValues.length}개의 값이 있어요</p>
|
||||
<div className="max-h-28 overflow-y-auto rounded-md border bg-background p-2 space-y-0.5">
|
||||
{categoryValues.map((cv) => (
|
||||
<div key={cv.valueCode} className="flex items-center gap-2 px-1.5 py-0.5 text-xs">
|
||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{cv.valueCode}</span>
|
||||
<span className="truncate">{cv.valueLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">기본 선택값</span>
|
||||
<Select value={config.defaultValue || "_none_"} onValueChange={(v) => updateConfig("defaultValue", v === "_none_" ? "" : v)}>
|
||||
<SelectTrigger className="h-8 w-[160px] text-sm"><SelectValue placeholder="선택 안함" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none_">선택 안함</SelectItem>
|
||||
{categoryValues.map((cv) => (<SelectItem key={cv.valueCode} value={cv.valueCode}>{cv.valueLabel}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loadingCategoryValues && categoryValues.length === 0 && (
|
||||
<p className="text-[10px] text-amber-600">카테고리 값이 없습니다. 테이블 카테고리 관리에서 값을 추가해주세요.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 테이블 참조 (entity) ─── */}
|
||||
{fieldType === "entity" && (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">테이블 참조</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">참조 테이블</p>
|
||||
<Select value={config.entityTable || ""} onValueChange={(v) => onChange({ ...config, entityTable: v, entityValueColumn: "", entityLabelColumn: "" })}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="테이블을 선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((t) => (<SelectItem key={t.tableName} value={t.tableName}>{t.displayName || t.tableComment ? `${t.displayName || t.tableComment} (${t.tableName})` : t.tableName}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{loadingColumns && <div className="text-muted-foreground flex items-center gap-2 text-xs"><Loader2 className="h-3 w-3 animate-spin" />컬럼 목록 로딩 중...</div>}
|
||||
{entityColumns.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">실제 저장되는 값</p>
|
||||
<Select value={config.entityValueColumn || ""} onValueChange={(v) => updateConfig("entityValueColumn", v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
||||
<SelectContent>{entityColumns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">사용자에게 보여지는 텍스트</p>
|
||||
<Select value={config.entityLabelColumn || ""} onValueChange={(v) => updateConfig("entityLabelColumn", v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
|
||||
<SelectContent>{entityColumns.map((col) => (<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel}</SelectItem>))}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">엔티티 선택 시 같은 폼의 관련 필드가 자동으로 채워져요</p>
|
||||
</div>
|
||||
)}
|
||||
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
|
||||
<p className="text-[10px] text-amber-600">선택한 테이블의 컬럼 정보를 불러올 수 없어요.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 채번 (테이블 기반) ─── */}
|
||||
{fieldType === "numbering" && (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListOrdered className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">채번 규칙</span>
|
||||
</div>
|
||||
{numberingTableName ? (
|
||||
<div className="rounded-md border bg-background p-2">
|
||||
<p className="text-xs text-muted-foreground">대상 테이블</p>
|
||||
<p className="text-sm font-medium mt-0.5">{numberingTableName}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-amber-600">화면에 연결된 테이블이 없어서 채번 규칙을 불러올 수 없어요.</p>
|
||||
)}
|
||||
{numberingTableName && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">채번 규칙</p>
|
||||
{loadingRules ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs py-1"><Loader2 className="h-3 w-3 animate-spin" />채번 규칙 로딩 중...</div>
|
||||
) : numberingRules.length > 0 ? (
|
||||
<Select value={config.autoGeneration?.numberingRuleId || ""} onValueChange={(v) => {
|
||||
onChange({ ...config, autoGeneration: { ...config.autoGeneration, type: "numbering_rule" as AutoGenerationType, numberingRuleId: v, tableName: numberingTableName } });
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="채번 규칙 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{numberingRules.map((rule) => (<SelectItem key={rule.ruleId} value={String(rule.ruleId)}>{rule.ruleName} ({rule.separator || "-"}{"{번호}"})</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">이 테이블에 등록된 채번 규칙이 없어요</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">읽기전용</p>
|
||||
<p className="text-[11px] text-muted-foreground">채번 필드는 자동 생성되므로 읽기전용을 권장해요</p>
|
||||
</div>
|
||||
<Switch checked={config.readonly !== false} onCheckedChange={(checked) => updateConfig("readonly", checked)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 데이터 필터 (선택형 + 테이블 있을 때만) ─── */}
|
||||
{isSelectGroup && fieldType !== "select" && filterTargetTable && (
|
||||
<div className="rounded-lg border bg-muted/30 p-4">
|
||||
<FilterConditionsSection
|
||||
filters={(config.filters as V2SelectFilter[]) || []}
|
||||
columns={filterColumns}
|
||||
loadingColumns={loadingFilterColumns}
|
||||
targetTable={filterTargetTable}
|
||||
onFiltersChange={(filters) => updateConfig("filters", filters)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ 3단계: 고급 설정 ═══ */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">고급 설정</span>
|
||||
</div>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", advancedOpen && "rotate-180")} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||||
{/* 선택형: 선택 방식, 복수 선택, 검색 등 */}
|
||||
{isSelectGroup && (
|
||||
<>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">선택 방식</p>
|
||||
<Select value={config.mode || "dropdown"} onValueChange={(v) => updateConfig("mode", v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dropdown">드롭다운</SelectItem>
|
||||
<SelectItem value="combobox">검색 가능 드롭다운</SelectItem>
|
||||
<SelectItem value="radio">라디오 버튼</SelectItem>
|
||||
<SelectItem value="check">체크박스</SelectItem>
|
||||
<Separator className="my-1" />
|
||||
<SelectItem value="tag">태그 선택</SelectItem>
|
||||
<SelectItem value="toggle">토글</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div><p className="text-sm">여러 개 선택</p><p className="text-[11px] text-muted-foreground">한 번에 여러 값을 선택할 수 있어요</p></div>
|
||||
<Switch checked={config.multiple || false} onCheckedChange={(v) => updateConfig("multiple", v)} />
|
||||
</div>
|
||||
{config.multiple && (
|
||||
<div className="ml-4 border-l-2 border-primary/20 pl-3">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">최대 선택 개수</span>
|
||||
<Input type="number" value={config.maxSelect ?? ""} onChange={(e) => updateConfig("maxSelect", e.target.value ? Number(e.target.value) : undefined)} placeholder="제한 없음" min={1} className="h-7 w-[100px] text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div><p className="text-sm">검색 기능</p><p className="text-[11px] text-muted-foreground">옵션이 많을 때 검색으로 찾을 수 있어요</p></div>
|
||||
<Switch checked={config.searchable || false} onCheckedChange={(v) => updateConfig("searchable", v)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div><p className="text-sm">선택 초기화</p><p className="text-[11px] text-muted-foreground">선택한 값을 지울 수 있는 X 버튼이 표시돼요</p></div>
|
||||
<Switch checked={config.allowClear !== false} onCheckedChange={(v) => updateConfig("allowClear", v)} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 입력형: 자동 생성 */}
|
||||
{!isSelectGroup && fieldType !== "numbering" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div><p className="text-sm">자동 생성</p><p className="text-[11px] text-muted-foreground">값이 자동으로 채워져요</p></div>
|
||||
<Switch checked={config.autoGeneration?.enabled || false} onCheckedChange={(checked) => updateConfig("autoGeneration", { ...config.autoGeneration || { type: "none", enabled: false }, enabled: checked })} />
|
||||
</div>
|
||||
{config.autoGeneration?.enabled && (
|
||||
<div className="ml-1 border-l-2 border-primary/20 pl-3">
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">생성 방식</p>
|
||||
<Select value={config.autoGeneration?.type || "none"} onValueChange={(v: AutoGenerationType) => updateConfig("autoGeneration", { ...config.autoGeneration, type: v })}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="자동생성 타입 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">자동생성 없음</SelectItem>
|
||||
<SelectItem value="uuid">UUID 생성</SelectItem>
|
||||
<SelectItem value="current_user">현재 사용자 ID</SelectItem>
|
||||
<SelectItem value="current_time">현재 시간</SelectItem>
|
||||
<SelectItem value="sequence">순차 번호</SelectItem>
|
||||
<SelectItem value="company_code">회사 코드</SelectItem>
|
||||
<SelectItem value="department">부서 코드</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
|
||||
<p className="text-[11px] text-muted-foreground mt-1">{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 입력 마스크 */}
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">입력 마스크</span>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5"># = 숫자, A = 문자, * = 모두</p>
|
||||
</div>
|
||||
<Input value={config.mask || ""} onChange={(e) => updateConfig("mask", e.target.value)} placeholder="###-####-####" className="h-7 w-[140px] text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
V2FieldConfigPanel.displayName = "V2FieldConfigPanel";
|
||||
|
||||
export default V2FieldConfigPanel;
|
||||
|
|
@ -46,10 +46,12 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
|||
const response = await apiClient.get("/admin/menus");
|
||||
if (response.data.success && response.data.data) {
|
||||
const allMenus = response.data.data;
|
||||
const level2UserMenus = allMenus.filter((menu: any) =>
|
||||
menu.menu_type === '1' && menu.lev === 2
|
||||
);
|
||||
setParentMenus(level2UserMenus);
|
||||
const userMenus = allMenus.filter((menu: any) => {
|
||||
const menuType = menu.menu_type || menu.menuType;
|
||||
const level = menu.level || menu.lev || menu.LEVEL;
|
||||
return menuType === '1' && (level === 2 || level === 3 || level === '2' || level === '3');
|
||||
});
|
||||
setParentMenus(userMenus);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("부모 메뉴 로드 실패:", error);
|
||||
|
|
@ -60,9 +62,12 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
|||
loadMenus();
|
||||
}, []);
|
||||
|
||||
const inputType = config.inputType || config.type || "text";
|
||||
|
||||
useEffect(() => {
|
||||
const loadRules = async () => {
|
||||
if (config.autoGeneration?.type !== "numbering_rule") return;
|
||||
const isNumbering = inputType === "numbering" || config.autoGeneration?.type === "numbering_rule";
|
||||
if (!isNumbering) return;
|
||||
if (!selectedMenuObjid) { setNumberingRules([]); return; }
|
||||
setLoadingRules(true);
|
||||
try {
|
||||
|
|
@ -78,9 +83,7 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
|||
}
|
||||
};
|
||||
loadRules();
|
||||
}, [selectedMenuObjid, config.autoGeneration?.type]);
|
||||
|
||||
const inputType = config.inputType || config.type || "text";
|
||||
}, [selectedMenuObjid, config.autoGeneration?.type, inputType]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -106,7 +109,24 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
|||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateConfig("inputType", item.value)}
|
||||
onClick={() => {
|
||||
if (item.value === "numbering") {
|
||||
const autoMenuObjid = selectedMenuObjid || menuObjid;
|
||||
onChange({
|
||||
...config,
|
||||
inputType: "numbering",
|
||||
autoGeneration: {
|
||||
...config.autoGeneration,
|
||||
type: "numbering_rule" as AutoGenerationType,
|
||||
selectedMenuObjid: autoMenuObjid,
|
||||
},
|
||||
readonly: config.readonly ?? true,
|
||||
});
|
||||
if (autoMenuObjid) setSelectedMenuObjid(autoMenuObjid);
|
||||
} else {
|
||||
updateConfig("inputType", item.value);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border p-2.5 text-left transition-all",
|
||||
inputType === item.value
|
||||
|
|
@ -129,15 +149,109 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* ─── 채번 타입 전용 안내 ─── */}
|
||||
{/* ─── 채번 타입 전용 설정 ─── */}
|
||||
{inputType === "numbering" && (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="rounded-md border border-primary/20 bg-primary/5 p-3">
|
||||
<p className="text-xs text-primary">
|
||||
채번 규칙은 <strong>테이블 관리</strong>에서 컬럼별로 설정돼요.
|
||||
화면에 배치된 컬럼의 채번 규칙이 자동으로 적용돼요.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<ListOrdered className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">채번 규칙</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">적용할 메뉴</p>
|
||||
{menuObjid && selectedMenuObjid === menuObjid ? (
|
||||
<div className="rounded-md border bg-background p-2">
|
||||
<p className="text-xs text-muted-foreground">현재 화면 메뉴 사용 중</p>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<p className="text-sm font-medium">
|
||||
{parentMenus.find((m: any) => m.objid === menuObjid)?.menu_name_kor
|
||||
|| parentMenus.find((m: any) => m.objid === menuObjid)?.translated_name
|
||||
|| `메뉴 #${menuObjid}`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedMenuObjid(undefined)}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
변경
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : loadingMenus ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs py-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
메뉴 목록 로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedMenuObjid ? String(selectedMenuObjid) : ""}
|
||||
onValueChange={(v) => {
|
||||
const objid = Number(v);
|
||||
setSelectedMenuObjid(objid);
|
||||
onChange({
|
||||
...config,
|
||||
autoGeneration: {
|
||||
...config.autoGeneration,
|
||||
type: "numbering_rule" as AutoGenerationType,
|
||||
selectedMenuObjid: objid,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="메뉴를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentMenus.map((menu: any) => (
|
||||
<SelectItem key={menu.objid} value={String(menu.objid)}>
|
||||
{menu.menu_name_kor || menu.translated_name || menu.menu_name || `메뉴 ${menu.objid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedMenuObjid && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">채번 규칙</p>
|
||||
{loadingRules ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs py-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
채번 규칙 로딩 중...
|
||||
</div>
|
||||
) : numberingRules.length > 0 ? (
|
||||
<Select
|
||||
value={config.autoGeneration?.numberingRuleId ? String(config.autoGeneration.numberingRuleId) : ""}
|
||||
onValueChange={(v) => {
|
||||
onChange({
|
||||
...config,
|
||||
autoGeneration: {
|
||||
...config.autoGeneration,
|
||||
type: "numbering_rule" as AutoGenerationType,
|
||||
numberingRuleId: Number(v),
|
||||
selectedMenuObjid,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="채번 규칙 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{numberingRules.map((rule) => (
|
||||
<SelectItem key={rule.ruleId} value={String(rule.ruleId)}>
|
||||
{rule.ruleName} ({rule.separator || "-"}{"{번호}"})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">선택한 메뉴에 등록된 채번 규칙이 없어요</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">읽기전용</p>
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ const SOURCE_CARDS = [
|
|||
icon: Database,
|
||||
title: "테이블 참조",
|
||||
description: "다른 테이블에서 가져와요",
|
||||
entityOnly: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
|
@ -279,6 +278,8 @@ interface V2SelectConfigPanelProps {
|
|||
inputType?: string;
|
||||
tableName?: string;
|
||||
columnName?: string;
|
||||
tables?: Array<{ tableName: string; displayName?: string; tableComment?: string }>;
|
||||
screenTableName?: string;
|
||||
}
|
||||
|
||||
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
||||
|
|
@ -287,6 +288,8 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
inputType,
|
||||
tableName,
|
||||
columnName,
|
||||
tables = [],
|
||||
screenTableName,
|
||||
}) => {
|
||||
const isEntityType = inputType === "entity" || config.source === "entity" || !!config.entityTable;
|
||||
const isCategoryType = inputType === "category";
|
||||
|
|
@ -342,11 +345,12 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
loadFilterColumns();
|
||||
}, [filterTargetTable]);
|
||||
|
||||
// 초기 source가 설정 안 된 경우에만 기본값 설정
|
||||
useEffect(() => {
|
||||
if (isCategoryType && config.source !== "category") {
|
||||
if (!config.source && isCategoryType) {
|
||||
onChange({ ...config, source: "category" });
|
||||
}
|
||||
}, [isCategoryType]);
|
||||
}, []);
|
||||
|
||||
const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
|
||||
if (!catTable || !catColumn) {
|
||||
|
|
@ -447,23 +451,13 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
updateConfig("options", newOptions);
|
||||
};
|
||||
|
||||
const effectiveSource = isCategoryType
|
||||
const effectiveSource = config.source === "code"
|
||||
? "category"
|
||||
: config.source === "code"
|
||||
? "category"
|
||||
: config.source || "static";
|
||||
: config.source || (isCategoryType ? "category" : "static");
|
||||
|
||||
const visibleCards = useMemo(() => {
|
||||
if (isCategoryType) {
|
||||
return SOURCE_CARDS.filter((c) => c.value === "category");
|
||||
}
|
||||
return SOURCE_CARDS.filter((c) => {
|
||||
if (c.entityOnly && !isEntityType) return false;
|
||||
return true;
|
||||
});
|
||||
}, [isCategoryType, isEntityType]);
|
||||
const visibleCards = SOURCE_CARDS;
|
||||
|
||||
const gridCols = isEntityType ? "grid-cols-3" : "grid-cols-2";
|
||||
const gridCols = "grid-cols-3";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -572,9 +566,25 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
<span className="text-sm font-medium">테이블 참조</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground">참조 테이블</p>
|
||||
<p className="mt-0.5 text-sm font-medium">{config.entityTable || "미설정"}</p>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">참조 테이블</p>
|
||||
<Select
|
||||
value={config.entityTable || ""}
|
||||
onValueChange={(v) => {
|
||||
onChange({ ...config, entityTable: v, entityValueColumn: "", entityLabelColumn: "" });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="테이블을 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.tableName} value={t.tableName}>
|
||||
{t.displayName || t.tableComment ? `${t.displayName || t.tableComment} (${t.tableName})` : t.tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{loadingColumns && (
|
||||
|
|
@ -628,16 +638,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loadingColumns && entityColumns.length === 0 && !config.entityTable && (
|
||||
<div className="rounded-md border-2 border-dashed p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">참조 테이블이 설정되지 않았어요</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">테이블 타입 관리에서 참조 테이블을 설정해주세요</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
|
||||
<p className="text-[10px] text-amber-600">
|
||||
테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.
|
||||
선택한 테이블의 컬럼 정보를 불러올 수 없어요. 테이블 타입 관리에서 컬럼 정보를 확인해주세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -302,9 +302,16 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
return type;
|
||||
};
|
||||
|
||||
const componentType = mapToV2ComponentType(rawComponentType);
|
||||
const mappedComponentType = mapToV2ComponentType(rawComponentType);
|
||||
|
||||
// 컴포넌트 타입 변환 완료
|
||||
// fieldType 기반 동적 컴포넌트 전환 (통합 필드 설정 패널에서 설정된 값)
|
||||
const componentType = (() => {
|
||||
const ft = (component as any).componentConfig?.fieldType;
|
||||
if (!ft) return mappedComponentType;
|
||||
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft)) return "v2-input";
|
||||
if (["select", "category", "entity"].includes(ft)) return "v2-select";
|
||||
return mappedComponentType;
|
||||
})();
|
||||
|
||||
// 🆕 조건부 렌더링 체크 (conditionalConfig)
|
||||
// componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교
|
||||
|
|
@ -738,7 +745,21 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
|
||||
const isEntityJoinColumn = fieldName?.includes(".");
|
||||
const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
|
||||
const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
|
||||
const rawMergedConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
|
||||
|
||||
// fieldType이 설정된 경우, source/inputType 보조 속성 자동 보완
|
||||
const mergedComponentConfig = (() => {
|
||||
const ft = rawMergedConfig?.fieldType;
|
||||
if (!ft) return rawMergedConfig;
|
||||
const patch: Record<string, any> = {};
|
||||
if (["select", "category", "entity"].includes(ft) && !rawMergedConfig.source) {
|
||||
patch.source = ft === "category" ? "category" : ft === "entity" ? "entity" : "static";
|
||||
}
|
||||
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft) && !rawMergedConfig.inputType) {
|
||||
patch.inputType = ft;
|
||||
}
|
||||
return Object.keys(patch).length > 0 ? { ...rawMergedConfig, ...patch } : rawMergedConfig;
|
||||
})();
|
||||
|
||||
// NOT NULL 기반 필수 여부를 component.required에 반영
|
||||
const notNullRequired = isColumnRequiredByMeta(screenTableName, baseColumnName);
|
||||
|
|
@ -755,17 +776,16 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
size: needsExternalHorizLabel
|
||||
? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined }
|
||||
: component.size || newComponent.defaultSize,
|
||||
position: component.position,
|
||||
config: mergedComponentConfig,
|
||||
componentConfig: mergedComponentConfig,
|
||||
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
|
||||
// componentConfig spread를 먼저 → 이후 명시적 속성이 override
|
||||
...(mergedComponentConfig || {}),
|
||||
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
|
||||
// size/position/style/label은 componentConfig spread 이후에 설정 (덮어쓰기 방지)
|
||||
size: needsExternalHorizLabel
|
||||
? { ...(component.size || newComponent.defaultSize), width: undefined }
|
||||
: component.size || newComponent.defaultSize,
|
||||
position: component.position,
|
||||
style: mergedStyle,
|
||||
// 수평 라벨 → 외부에서 처리하므로 label 전달 안 함
|
||||
label: needsExternalHorizLabel ? undefined : effectiveLabel,
|
||||
// NOT NULL 메타데이터 포함된 필수 여부 (V2Hierarchy 등 직접 props.required 참조하는 컴포넌트용)
|
||||
required: effectiveRequired,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { V2InputConfigPanel } from "@/components/v2/config-panels/V2InputConfigPanel";
|
||||
import { V2FieldConfigPanel } from "@/components/v2/config-panels/V2FieldConfigPanel";
|
||||
import { V2Input } from "@/components/v2/V2Input";
|
||||
|
||||
export const V2InputDefinition = createComponentDefinition({
|
||||
|
|
@ -72,7 +72,7 @@ export const V2InputDefinition = createComponentDefinition({
|
|||
tags: ["input", "text", "number", "v2"],
|
||||
|
||||
// 설정 패널
|
||||
configPanel: V2InputConfigPanel,
|
||||
configPanel: V2FieldConfigPanel,
|
||||
});
|
||||
|
||||
export default V2InputDefinition;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { V2SelectConfigPanel } from "@/components/v2/config-panels/V2SelectConfigPanel";
|
||||
import { V2FieldConfigPanel } from "@/components/v2/config-panels/V2FieldConfigPanel";
|
||||
import { V2Select } from "@/components/v2/V2Select";
|
||||
|
||||
export const V2SelectDefinition = createComponentDefinition({
|
||||
|
|
@ -82,7 +82,7 @@ export const V2SelectDefinition = createComponentDefinition({
|
|||
tags: ["select", "dropdown", "combobox", "v2"],
|
||||
|
||||
// 설정 패널
|
||||
configPanel: V2SelectConfigPanel,
|
||||
configPanel: V2FieldConfigPanel,
|
||||
});
|
||||
|
||||
export default V2SelectDefinition;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import type { ConfigPanelContext } from "@/lib/registry/components/common/Config
|
|||
// 컴포넌트별 ConfigPanel 동적 import 맵
|
||||
const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||
// ========== V2 컴포넌트 ==========
|
||||
"v2-input": () => import("@/components/v2/config-panels/V2InputConfigPanel"),
|
||||
"v2-select": () => import("@/components/v2/config-panels/V2SelectConfigPanel"),
|
||||
"v2-input": () => import("@/components/v2/config-panels/V2FieldConfigPanel"),
|
||||
"v2-select": () => import("@/components/v2/config-panels/V2FieldConfigPanel"),
|
||||
"v2-date": () => import("@/components/v2/config-panels/V2DateConfigPanel"),
|
||||
"v2-list": () => import("@/components/v2/config-panels/V2ListConfigPanel"),
|
||||
"v2-media": () => import("@/components/v2/config-panels/V2MediaConfigPanel"),
|
||||
|
|
@ -205,6 +205,7 @@ export interface ComponentConfigPanelProps {
|
|||
menuObjid?: number;
|
||||
allComponents?: any[];
|
||||
currentComponent?: any;
|
||||
componentType?: string;
|
||||
}
|
||||
|
||||
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
||||
|
|
@ -217,6 +218,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
menuObjid,
|
||||
allComponents,
|
||||
currentComponent,
|
||||
componentType,
|
||||
}) => {
|
||||
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
|
@ -484,6 +486,8 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
onConfigChange={onChange}
|
||||
context={context}
|
||||
screenTableName={screenTableName}
|
||||
tableName={screenTableName}
|
||||
columnName={currentComponent?.columnName || config?.columnName || config?.fieldName}
|
||||
tableColumns={selectedTableColumns}
|
||||
tables={tables}
|
||||
allTables={allTablesList.length > 0 ? allTablesList : tables}
|
||||
|
|
@ -493,6 +497,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
currentComponent={currentComponent}
|
||||
screenComponents={screenComponents}
|
||||
inputType={currentComponent?.inputType || config?.inputType}
|
||||
componentType={componentType || componentId}
|
||||
sourceTableColumns={sourceTableColumns}
|
||||
targetTableColumns={targetTableColumns}
|
||||
onSourceTableChange={handleSourceTableChange}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
# 통합 필드 타입 Config Panel 테스트 가이드
|
||||
|
||||
## 테스트 대상
|
||||
- **컴포넌트:** V2FieldConfigPanel (통합 필드 설정 패널)
|
||||
- **위치:** `frontend/components/v2/config-panels/V2FieldConfigPanel.tsx`
|
||||
- **연결:** v2-input, v2-select 컴포넌트 선택 시 오른쪽 속성 패널에 표시
|
||||
|
||||
## 7개 필드 타입 카드
|
||||
| 카드 | value | 설명 |
|
||||
|------|-------|------|
|
||||
| 텍스트 | text | 일반 텍스트 입력 |
|
||||
| 숫자 | number | 숫자만 입력 |
|
||||
| 여러 줄 | textarea | 긴 텍스트 입력 |
|
||||
| 셀렉트 | select | 직접 옵션 선택 |
|
||||
| 카테고리 | category | 등록된 선택지 |
|
||||
| 테이블 참조 | entity | 다른 테이블 참조 |
|
||||
| 채번 | numbering | 자동 번호 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 테스트 절차
|
||||
|
||||
### 1단계: 로그인
|
||||
1. 브라우저에서 `http://localhost:9771/login` 접속
|
||||
2. 로그인 정보:
|
||||
- **User ID:** `admin`
|
||||
- **Password:** `wace1234!`
|
||||
3. "로그인" 버튼 클릭
|
||||
4. 페이지 로드 완료까지 대기 (약 3~5초)
|
||||
|
||||
**확인:** 로그인 성공 시 메인 대시보드 또는 홈 화면으로 이동
|
||||
|
||||
---
|
||||
|
||||
### 2단계: 화면 디자이너 열기
|
||||
|
||||
**경로 1 (권장):**
|
||||
- 사이드바에서 **"화면 관리"** 또는 **"화면 설정"** 메뉴 클릭
|
||||
- 화면 목록에서 기존 화면 선택 후 **"설계"** 또는 **"편집"** 버튼 클릭
|
||||
|
||||
**경로 2 (URL 직접):**
|
||||
- `http://localhost:9771/admin/screenMng/screenMngList` 접속
|
||||
- 화면 목록에서 화면 선택 후 설계 버튼 클릭
|
||||
|
||||
**경로 3 (특정 화면 직접):**
|
||||
- `http://localhost:9771/admin/screenMng/screenMngList?openDesigner=60` 접속
|
||||
- (60은 화면 ID - 존재하는 화면 ID로 변경)
|
||||
|
||||
**확인:** 화면 디자이너가 전체 화면으로 열림 (캔버스, 좌측 컴포넌트 목록, 오른쪽 속성 패널)
|
||||
|
||||
---
|
||||
|
||||
### 3단계: v2-input 컴포넌트 찾기 및 선택
|
||||
|
||||
**v2-input 컴포넌트 찾기:**
|
||||
- v2-input은 주로 **테이블 컬럼을 캔버스로 드래그**할 때 자동 생성됨
|
||||
- 또는 좌측 패널에서 "입력" 관련 컴포넌트를 드래그하여 배치
|
||||
- 캔버스에 있는 **텍스트 입력 필드**를 클릭
|
||||
|
||||
**v2-input 식별:**
|
||||
- 선택 시 오른쪽 패널에 "이 필드는 어떤 유형인가요?" 문구와 7개 카드가 보이면 v2-input
|
||||
- 레거시 위젯은 "입력 타입", "세부 타입 선택" 등 다른 UI
|
||||
|
||||
**확인:** v2-input 선택 시 오른쪽 패널에 7개 카드 표시
|
||||
|
||||
---
|
||||
|
||||
### 4단계: 7개 카드 확인
|
||||
|
||||
오른쪽 속성 패널에서 다음 7개 카드가 **3열 그리드**로 표시되는지 확인:
|
||||
|
||||
1. **텍스트** - "일반 텍스트 입력"
|
||||
2. **숫자** - "숫자만 입력"
|
||||
3. **여러 줄** - "긴 텍스트 입력"
|
||||
4. **셀렉트** - "직접 옵션 선택"
|
||||
5. **카테고리** - "등록된 선택지"
|
||||
6. **테이블 참조** - "다른 테이블 참조"
|
||||
7. **채번** - "자동 번호 생성"
|
||||
|
||||
**확인:** 7개 카드 모두 표시, 선택된 카드에 파란색 테두리/배경
|
||||
|
||||
---
|
||||
|
||||
### 5단계: 셀렉트 카드 클릭 테스트
|
||||
|
||||
1. **텍스트** 카드가 선택된 상태에서 시작 (기본값)
|
||||
2. **셀렉트** 카드 클릭
|
||||
3. 아래 상세 설정이 **텍스트용**에서 **셀렉트용**으로 바뀌는지 확인
|
||||
|
||||
**텍스트 선택 시 표시:**
|
||||
- "안내 텍스트" (placeholder) 입력 필드
|
||||
- "입력 형식" (선택사항)
|
||||
- "최대 길이" 등
|
||||
|
||||
**셀렉트 선택 시 표시:**
|
||||
- "옵션 목록" 섹션
|
||||
- "추가" 버튼
|
||||
- "아직 옵션이 없어요" 또는 옵션 목록
|
||||
- "기본 선택값" 드롭다운
|
||||
|
||||
**확인:** 카드 클릭 시 상세 설정 영역이 해당 타입에 맞게 전환됨
|
||||
|
||||
---
|
||||
|
||||
### 6단계: 기타 카드 전환 테스트
|
||||
|
||||
| 클릭 카드 | 예상 상세 설정 |
|
||||
|-----------|----------------|
|
||||
| 숫자 | 최소/최대값, 단위, 슬라이더 등 |
|
||||
| 여러 줄 | 줄 수 설정 |
|
||||
| 카테고리 | 테이블/컬럼 정보, 카테고리 값 목록 |
|
||||
| 테이블 참조 | 참조 테이블 선택, 값/라벨 컬럼 |
|
||||
| 채번 | 메뉴 선택, 채번 규칙 선택 |
|
||||
|
||||
**확인:** 각 카드 클릭 시 해당 타입의 상세 설정이 표시됨
|
||||
|
||||
---
|
||||
|
||||
## 예상 결과 요약
|
||||
|
||||
### 정상 동작
|
||||
- [x] 로그인 성공
|
||||
- [x] 화면 디자이너 접근 가능
|
||||
- [x] v2-input 컴포넌트 선택 시 오른쪽 패널에 7개 카드 표시
|
||||
- [x] "셀렉트" 카드 클릭 시 옵션 목록 관리 UI로 전환
|
||||
- [x] 다른 카드 클릭 시 해당 타입의 상세 설정으로 전환
|
||||
|
||||
### 오류 상황
|
||||
- **"로드 실패"** 메시지: ConfigPanel 동적 로드 실패
|
||||
- **"설정 패널 없음"** 메시지: v2-input 매핑 누락
|
||||
- **7개 카드 미표시:** V2FieldConfigPanel 대신 다른 패널이 렌더링됨
|
||||
- **카드 클릭 무반응:** handleFieldTypeChange 미동작 또는 config 업데이트 실패
|
||||
|
||||
---
|
||||
|
||||
## 코드 흐름 (참고)
|
||||
|
||||
```
|
||||
ScreenDesigner
|
||||
└─ V2PropertiesPanel (selectedComponent)
|
||||
└─ hasComponentConfigPanel("v2-input") === true
|
||||
└─ DynamicComponentConfigPanel (componentId="v2-input")
|
||||
└─ getComponentConfigPanel("v2-input")
|
||||
└─ import("@/components/v2/config-panels/V2FieldConfigPanel")
|
||||
└─ V2FieldConfigPanel (7 cards + handleFieldTypeChange)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 스크린샷 체크리스트
|
||||
|
||||
1. **로그인 화면** - 로그인 전
|
||||
2. **화면 관리 목록** - 화면 디자이너 진입 전
|
||||
3. **화면 디자이너** - v2-input 미선택 상태
|
||||
4. **7개 카드 표시** - v2-input 선택 후 오른쪽 패널
|
||||
5. **셀렉트 상세 설정** - 셀렉트 카드 클릭 후
|
||||
6. **다른 타입 상세 설정** - 예: 채번 또는 테이블 참조
|
||||
|
||||
---
|
||||
|
||||
## 문제 발생 시 디버깅
|
||||
|
||||
### Console 확인
|
||||
```javascript
|
||||
// 선택된 컴포넌트 타입 확인
|
||||
// React DevTools 또는 전역 상태에서
|
||||
console.log(selectedComponent?.componentConfig?.type);
|
||||
// "v2-input" 이어야 함
|
||||
```
|
||||
|
||||
### Network 확인
|
||||
- `/api/admin/webTypes` - 200 OK
|
||||
- 화면 레이아웃 API - 200 OK
|
||||
|
||||
### 컴포넌트가 v2-input이 아닌 경우
|
||||
- 테이블 컬럼을 캔버스로 드래그하면 v2-input 생성
|
||||
- 기존 화면에 레거시 text-input 등이 있으면 V2InputConfigPanel(5개 카드)이 표시될 수 있음
|
||||
- V2FieldConfigPanel은 **7개 카드** (텍스트, 숫자, 여러 줄, 셀렉트, 카테고리, 테이블 참조, 채번)
|
||||
Loading…
Reference in New Issue