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:
DDD1542 2026-03-12 15:01:05 +09:00
parent 83aa8f3250
commit 80be7c5a76
11 changed files with 1163 additions and 70 deletions

View File

@ -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>

View File

@ -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) => {

View File

@ -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}

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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}

View File

@ -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개 카드** (텍스트, 숫자, 여러 줄, 셀렉트, 카테고리, 테이블 참조, 채번)