Consolidate canonical input migration
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m17s

Remove legacy v2 input/select and file/media runtimes, add canonical option/file loaders, and document Codex handoff.
This commit is contained in:
DDD1542
2026-05-12 18:36:43 +09:00
parent 90035dd5c6
commit 4a8413000b
79 changed files with 2359 additions and 14028 deletions
@@ -442,11 +442,8 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
const isInputType =
compType?.includes("input") ||
compType?.includes("select") ||
compType?.includes("textarea") ||
compType?.includes("v2-input") ||
compType?.includes("v2-select") ||
compType?.includes("v2-media") ||
compType?.includes("file-upload");
compType?.includes("textarea");
// (옛 v2-media / file-upload 매칭은 Phase D.5 에서 제거 — canonical input 의 includes("input") 으로 포함)
const hasColumnName = !!(comp as any).columnName;
return isInputType && hasColumnName;
});
@@ -699,7 +699,7 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
return {
valueId: selectedId,
valueCode: node.value_code, // value_code 추가 (V2Select 호환)
valueCode: node.value_code, // value_code 추가 (canonical input 의 카테고리 옵션 매칭용)
valueLabel: node.value_label,
valuePath: pathParts.join(" > "),
};
@@ -713,7 +713,7 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
const newMapping: CategoryFormatMapping = {
category_value_id: selectedInfo.valueId,
category_value_code: selectedInfo.valueCode, // V2Select에서 valueCode를 value로 사용하므로 매칭용 저장
category_value_code: selectedInfo.valueCode, // canonical input 의 카테고리 옵션이 valueCode 를 value 로 사용하므로 매칭용 저장
category_value_label: selectedInfo.valueLabel,
category_value_path: selectedInfo.valuePath,
format: newFormat.trim(),
@@ -5,15 +5,15 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file";
// uploadFilesAndCreateData 직접 import 제거 (Phase D.5) — master save 의 uploadFiles 동적 import 가 대체
import { toast } from "sonner";
import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen";
import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent";
import { ComponentData, WidgetComponent, DataTableComponent, ButtonTypeConfig } from "@/types/screen";
// 옛 FileUploadComponent 직접 import 는 Phase D.5 에서 제거 — canonical input 의 FilePicker 가 처리.
import { InteractiveDataTable } from "./InteractiveDataTable";
import { DynamicWebTypeRenderer } from "@/lib/registry";
import { DynamicComponentRenderer, isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
import { isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
import { FlowVisibilityConfig } from "@/types/screen-management";
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
@@ -406,10 +406,10 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
);
}
// 파일 컴포넌트 처리
if (isFileComponent(comp)) {
return renderFileComponent(comp as FileComponent);
}
// 파일 컴포넌트 — Phase D.5 에서 canonical input 으로 일원화. 옛 FileUploadComponent
// 전용 분기 제거. componentType="file-upload" 등 옛 화면은 일반 경로로 빠져 빈 렌더
// (운영 단계 X — alias/fallback 추가 금지 원칙).
// file-upload / image-widget / image-display / v2-media / v2-file-upload 는 더 이상 별도 분기 없음.
// 버튼 컴포넌트 또는 위젯이 아닌 경우 DynamicComponentRenderer 사용
if (comp.type !== "widget") {
@@ -619,30 +619,100 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
const masterFormData: Record<string, any> = {};
// 파일 업로드 컴포넌트의 columnName 목록 수집 (v2-media, file-upload 모두 포함)
const mediaColumnNames = new Set(
allComponents
.filter((c: any) =>
c.componentType === "v2-media" ||
c.componentType === "file-upload" ||
c.url?.includes("v2-media") ||
c.url?.includes("file-upload")
)
.map((c: any) => c.column_name || c.componentConfig?.column_name)
.filter(Boolean)
);
Object.entries(formData).forEach(([key, value]) => {
// 파일 컬럼 수집:
// - 옛 v2-media / file-upload (Phase D.5 폐기 전 호환)
// - canonical input 의 file 분기 (componentType === "input" + config.type === "file"
// 또는 config.kind === "attach" 또는 config.format ∈ {file, image, doc})
const isFileColumnComponent = (c: any): boolean => {
const t = c.componentType || c.config?.componentType || c.overrides?.type;
if (t === "v2-media" || t === "file-upload") return true;
if (c.url?.includes("v2-media") || c.url?.includes("file-upload")) return true;
if (t === "input") {
const cc = c.componentConfig || c.config || c.overrides || {};
if (cc.type === "file") return true;
if (cc.kind === "attach") return true;
if (cc.format === "file" || cc.format === "image" || cc.format === "doc") return true;
}
return false;
};
const fileComponentByColumn = new Map<string, any>();
for (const c of allComponents as any[]) {
if (!isFileColumnComponent(c)) continue;
const col =
c.column_name ||
c.columnName ||
c.componentConfig?.column_name ||
c.componentConfig?.columnName ||
c.config?.column_name ||
c.config?.columnName ||
c.overrides?.column_name ||
c.overrides?.columnName;
if (col) fileComponentByColumn.set(col, c);
}
// canonical input file 값 변환:
// - File / File[] → uploadFiles 호출 후 응답 FileInfo[].id (또는 server_path) 로 치환
// - string / string[] → 이미 저장된 값으로 유지
// - null / undefined / "" → 그대로
const { uploadFiles: uploadFilesApi } = await import("@/lib/api/file");
const uploadIfFileValue = async (
rawValue: unknown,
columnName: string,
comp: any,
): Promise<unknown> => {
if (rawValue == null) return rawValue;
const isArr = Array.isArray(rawValue);
const arr = isArr ? (rawValue as unknown[]) : [rawValue];
// 이미 string 만 있으면 업로드 skip
if (arr.every((v) => typeof v === "string")) {
return rawValue;
}
const files = arr.filter((v): v is File => typeof File !== "undefined" && v instanceof File);
const existingStrings = arr.filter((v): v is string => typeof v === "string");
if (files.length === 0) {
// File 도 string 도 아닌 값은 그대로 (FileInfo 객체 등)
return rawValue;
}
try {
const cc = comp?.componentConfig || comp?.config || comp?.overrides || {};
const res = await uploadFilesApi({
files,
tableName: screenInfo?.tableName,
fieldName: columnName,
columnName: columnName,
recordId: formData.id,
isVirtualFileColumn: !!cc.isVirtualFileColumn,
autoLink: true,
company_code: user?.company_code,
isRecordMode: !!formData.id,
});
const uploaded = res?.files || res?.data || [];
const uploadedIds = uploaded
.map((f: any) => f?.id ?? f?.server_path ?? f?.server_filename)
.filter((v: any) => v != null);
if (!isArr) return uploadedIds[0] ?? null;
return [...existingStrings, ...uploadedIds];
} catch (uploadErr) {
console.error(`[canonical file] 업로드 실패: column=${columnName}`, uploadErr);
// 업로드 실패 시 안전하게 기존 string 만 유지
return isArr ? existingStrings : (existingStrings[0] ?? null);
}
};
for (const [key, value] of Object.entries(formData)) {
const comp = fileComponentByColumn.get(key);
if (comp) {
// 파일 컬럼 — File 객체 업로드 후 id 로 치환
masterFormData[key] = await uploadIfFileValue(value, key, comp);
continue;
}
if (!Array.isArray(value)) {
masterFormData[key] = value;
} else if (mediaColumnNames.has(key)) {
masterFormData[key] = value.length > 0 ? value[0] : null;
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
} else {
console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
}
});
}
const saveData: DynamicFormData = {
table_name: screenInfo.tableName,
@@ -1000,117 +1070,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
);
};
// 파일 컴포넌트 렌더링
const renderFileComponent = (comp: FileComponent) => {
const { label, readonly } = comp;
const fieldName = comp.column_name || comp.id;
// 화면 ID 추출 (URL에서)
const screenId =
screenInfo?.id ||
(typeof window !== "undefined" && window.location.pathname.includes("/screens/")
? parseInt(window.location.pathname.split("/screens/")[1])
: null);
return (
<div className="h-full w-full">
{/* 실제 FileUploadComponent 사용 */}
<FileUploadComponent
component={comp}
componentConfig={{
...comp.file_config,
multiple: comp.file_config?.multiple !== false,
accept: (Array.isArray(comp.file_config?.accept) ? comp.file_config!.accept!.join(",") : (comp.file_config?.accept as unknown as string)) || "*/*",
maxSize: (comp.file_config?.max_size || 10) * 1024 * 1024, // MB to bytes
disabled: readonly,
}}
componentStyle={{
width: "100%",
height: "100%",
}}
className="h-full w-full"
isInteractive={true}
isDesignMode={false}
formData={{
screen_id: screenId, // 🎯 화면 ID 전달
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
autoLink: true, // 자동 연결 활성화
linkedTable: "screen_files", // 연결 테이블
recordId: screenId, // 레코드 ID
columnName: fieldName, // 컬럼명 (중요!)
isVirtualFileColumn: true, // 가상 파일 컬럼
id: formData.id,
...formData,
}}
onFormDataChange={(data) => {
// console.log("📝 실제 화면 파일 업로드 완료:", data);
if (onFormDataChange) {
Object.entries(data).forEach(([key, value]) => {
onFormDataChange(key, value);
});
}
}}
onUpdate={(updates) => {
console.log("🔄🔄🔄 실제 화면 파일 컴포넌트 업데이트:", {
componentId: comp.id,
hasUploadedFiles: !!updates.uploadedFiles,
filesCount: updates.uploadedFiles?.length || 0,
hasLastFileUpdate: !!updates.lastFileUpdate,
updates,
});
// 파일 업로드/삭제 완료 시 formData 업데이터
if (updates.uploadedFiles && onFormDataChange) {
onFormDataChange(fieldName, updates.uploadedFiles);
}
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생 (업로드/삭제 모두)
if (updates.uploadedFiles !== undefined && typeof window !== "undefined") {
// 업로드인지 삭제인지 판단 (lastFileUpdate가 있으면 변경사항 있음)
const action = updates.lastFileUpdate ? "update" : "sync";
const eventDetail = {
componentId: comp.id,
files: updates.uploadedFiles,
fileCount: updates.uploadedFiles.length,
action: action,
timestamp: updates.lastFileUpdate || Date.now(),
source: "realScreen", // 실제 화면에서 온 이벤트임을 표시
};
// console.log("🚀🚀🚀 실제 화면 파일 변경 이벤트 발생:", eventDetail);
const event = new CustomEvent("globalFileStateChanged", {
detail: eventDetail,
});
window.dispatchEvent(event);
// console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 이벤트 발생 완료");
// 추가 지연 이벤트들 (화면설계 모드가 열려있을 때를 대비)
setTimeout(() => {
// console.log("🔄 실제 화면 추가 이벤트 발생 (지연 100ms)");
window.dispatchEvent(
new CustomEvent("globalFileStateChanged", {
detail: { ...eventDetail, delayed: true },
}),
);
}, 100);
setTimeout(() => {
// console.log("🔄 실제 화면 추가 이벤트 발생 (지연 500ms)");
window.dispatchEvent(
new CustomEvent("globalFileStateChanged", {
detail: { ...eventDetail, delayed: true, attempt: 2 },
}),
);
}, 500);
}
}}
/>
</div>
);
};
// 메인 렌더링
const { type, position, size, style = {} } = component;
@@ -1121,13 +1080,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
// 라벨 표시 여부 확인 (V2 입력 컴포넌트)
const compType = (component as any).componentType || "";
const isV2InputComponent =
compType === "v2-input" || compType === "v2-select";
const hasVisibleLabel = isV2InputComponent &&
style?.label_display !== false &&
(style?.label_text || (component as any).label);
// 라벨 표시 여부 확인.
// ★ 2026-05-12 Phase D.2: V2 입력/선택 폐기 — 외부 라벨 오프셋 분기는 그 둘 전용이었음.
// canonical `input` 은 InputComponent 가 자체적으로 라벨을 absolute 로 그리므로 외부 처리 불필요.
// 다른 v2-* 컴포넌트는 원래 이 분기에 들어오지 않았음.
const hasVisibleLabel = false;
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
const labelPos = style?.label_position || "top";
@@ -5099,7 +5099,7 @@ export default function ScreenDesigner({
newComponent = {
id: generateComponentId(),
type: "component", // ✅ V2 컴포넌트 시스템 사용
type: "component", // ✅ canonical component system
label: column.columnLabel || column.columnName,
tableName: table.tableName,
columnName: column.columnName,
@@ -5129,7 +5129,7 @@ export default function ScreenDesigner({
labelMarginBottom: "6px",
},
componentConfig: {
type: v2Mapping.componentType, // v2-input, v2-select 등
type: v2Mapping.componentType, // input 등 canonical component id
...v2Mapping.componentConfig,
},
};
@@ -5137,7 +5137,7 @@ export default function ScreenDesigner({
return;
}
} else {
// 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용
// 일반 캔버스에 드롭한 경우 - canonical component system 사용
const v2Mapping = createV2ConfigFromColumn({
widgetType: column.widgetType,
columnName: column.columnName,
@@ -5166,7 +5166,7 @@ export default function ScreenDesigner({
newComponent = {
id: generateComponentId(),
type: "component", // ✅ V2 컴포넌트 시스템 사용
type: "component", // ✅ canonical component system
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName,
columnName: column.columnName,
@@ -5195,7 +5195,7 @@ export default function ScreenDesigner({
labelMarginBottom: "8px",
},
componentConfig: {
type: v2Mapping.componentType, // v2-input, v2-select 등
type: v2Mapping.componentType, // input 등 canonical component id
...v2Mapping.componentConfig,
},
};
@@ -8275,9 +8275,6 @@ export default function ScreenDesigner({
: "";
const fullKey = `${component.id}-${fileStateKey}-${styleKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`;
// 🔧 v2-input 계열 컴포넌트 key 변경 로그 (디버그 완료 - 주석 처리)
// if (component.id.includes("v2-") || component.widgetType?.includes("v2-")) { console.log("🔑 RealtimePreview key:", { id: component.id, styleKey, labelDisplay: component.style?.labelDisplay, forceRenderTrigger, fullKey }); }
// 🆕 labelDisplay 변경 시 새 객체로 강제 변경 감지
const componentWithLabel = {
...displayComponent,
@@ -2027,9 +2027,9 @@ function OverviewTab({
return comp;
});
// 폼 화면용 필드 추가/제거 처리 (개별 input 컴포넌트)
// 폼 화면용 필드 추가/제거 처리 (개별 canonical input 컴포넌트)
if (!columnChanged) {
// 폼 화면 필드 추가: 새 text-input 컴포넌트 생성
// 폼 화면 필드 추가: 새 input 컴포넌트 생성
if (isAddingField && newColumn) {
console.log("[handleColumnChange] 폼 화면 필드 추가 시도", { newColumn });
@@ -2049,21 +2049,22 @@ function OverviewTab({
const newY = maxY + 10;
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 새 text-input 컴포넌트 생성
const newComponent = {
id: newComponentId,
type: "component",
label: newColumn,
columnName: newColumn,
bindField: newColumn,
widgetType: "text-input",
componentType: "text-input",
widgetType: "text",
componentType: "input",
position: { x: 20, y: newY, z: 1 },
size: { width: 300, height: 30 },
gridColumns: 4,
componentConfig: {
type: "text-input",
web_type: "text-input",
kind: "input",
type: "text",
format: "free",
web_type: "text",
placeholder: `${newColumn}을(를) 입력하세요`,
},
webTypeConfig: {},
@@ -5057,4 +5058,3 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi
}
export default ScreenSettingModal;
@@ -101,11 +101,11 @@ export function ComponentsPanel({
"number-input",
"date-input",
"textarea-basic",
// V2 컴포넌트로 대체됨
"image-widget", // → V2Media (image)
"file-upload", // → input (type='file') 로 대체
"entity-search-input", // → V2Select (entity 모드)
"autocomplete-search-input", // → V2Select (autocomplete 모드)
// canonical input 으로 대체됨 (Phase D.4)
"image-widget", // → canonical input (type='file', format='image')
"file-upload", // → canonical input (type='file', format='file')
"entity-search-input", // → canonical input (entity 모드)
"autocomplete-search-input", // → canonical input (autocomplete 모드)
// DataFlow 전용 (일반 화면에서 불필요)
"mail-recipient-selector",
// 현재 사용 안함
@@ -117,8 +117,8 @@ export function ComponentsPanel({
"tax-invoice-list", // 세금계산서 전용
"customer-item-mapping", // 고객-품목 매핑 전용
// card-display는 별도 컴포넌트로 유지
// v2-media로 통합됨
"image-display", // → v2-media (image)
// canonical input 으로 통합됨 (Phase D.4)
"image-display", // → canonical input (type='file', format='image')
// 공통코드관리로 통합 예정
"category-manager", // → 공통코드관리 기능으로 통합 예정
// 분할 패널 정리
@@ -127,8 +127,8 @@ export function ComponentsPanel({
"accordion-basic", // 아코디언 컴포넌트
"conditional-container", // 조건부 컨테이너
"universal-form-modal", // 범용 폼 모달
// 통합 미디어 (테이블 컬럼 입력 타입으로 사용)
"v2-media", // → 테이블 컬럼의 image/file 입력 타입으로 사용
// 옛 v2-media — Phase D.4 에서 canonical input 으로 흡수. hidden 유지 (생성 차단)
"v2-media",
// 플로우 위젯 숨김 처리
"flow-widget",
// 선택 항목 상세입력 - 거래처 품목 추가 등에서 사용
@@ -156,11 +156,10 @@ export function ComponentsPanel({
"v2-table-search-widget", // → search
// table-search-widget, autocomplete-search-input 은 기존에 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase B-1): 필드 입력 20+종 → `input`
"v2-input", // → input (type='text'/'number')
"v2-select", // → input (type='select')
// (V2 입력/선택은 Phase D.2 에서 완전 폐기 — 등록/생성 경로 자체 삭제, hidden 목록에 둘 필요 없음)
"v2-category-manager", // → input (type='select', 추후 category 특화)
"v2-file-upload", // → input (type='file')
"v2-media", // → input (type='file')
"v2-file-upload", // → canonical input (type='file', Phase D.4)
// v2-media 는 이미 위에서 hidden 처리됨
// v2-numbering-rule: 폐기 (2026-05-11). admin 페이지 /admin/systemMng/numberingRuleList 로 대체
"v2-location-swap-selector", // → input (type='entity')
// 아래 legacy 들은 이미 상단 "기본 입력 컴포넌트" 섹션에서 hidden:
@@ -198,7 +197,6 @@ export function ComponentsPanel({
"section-card", // → v2-section-card
"location-swap-selector", // → v2-location-swap-selector
"rack-structure", // → v2-rack-structure
"v2-select", // → v2-select (아래 v2Components에서 별도 처리)
"v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리)
"repeat-container", // → v2-repeat-container
"repeat-screen-modal", // → v2-repeat-screen-modal
@@ -46,7 +46,6 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { columnMetaCache, loadColumnMeta } from "@/lib/registry/DynamicComponentRenderer";
import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
import StyleEditor from "../StyleEditor";
import { Slider } from "@/components/ui/slider";
@@ -63,6 +62,32 @@ import { Zap } from "lucide-react";
import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel";
import { ConditionalConfig } from "@/types/v2-components";
type ConfigPanelTableOption = {
tableName: string;
displayName?: string;
tableComment?: string;
};
function normalizeConfigPanelTables(raw: any): ConfigPanelTableOption[] {
const items = Array.isArray(raw)
? raw
: Array.isArray(raw?.tables)
? raw.tables
: [];
return items
.map((table: any) => {
const tableName = table?.tableName || table?.table_name || table?.name;
if (!tableName) return null;
return {
tableName,
displayName: table?.displayName || table?.display_name || table?.tableLabel || table?.table_label,
tableComment: table?.tableComment || table?.table_comment || table?.description,
};
})
.filter((table: ConfigPanelTableOption | null): table is ConfigPanelTableOption => table !== null);
}
interface V2PropertiesPanelProps {
selectedComponent?: ComponentData;
tables: TableInfo[];
@@ -104,25 +129,10 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
const [localWidth, setLocalWidth] = useState<string>("");
// 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용)
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
const [allTables, setAllTables] = useState<ConfigPanelTableOption[]>([]);
// 🆕 선택된 컴포넌트의 테이블에 대한 columnMeta 캐시가 비어 있으면 로드 후 재렌더
const [columnMetaVersion, setColumnMetaVersion] = useState(0);
useEffect(() => {
if (!selectedComponent) return;
const tblName =
(selectedComponent as any).tableName ||
currentTable?.tableName ||
tables?.[0]?.tableName;
if (!tblName) return;
if (columnMetaCache[tblName]) return;
loadColumnMeta(tblName).then(() => setColumnMetaVersion((v) => v + 1));
}, [
selectedComponent?.id,
(selectedComponent as any)?.tableName,
currentTable?.tableName,
tables?.[0]?.tableName,
]);
// V2 입력/선택 폐기 (2026-05-12) — DB input_type 사전 fetch 가 이 prefetch 의 유일한
// 소비처였음. canonical input 의 InvFieldConfigPanel 은 패널 자체에서 메타를 가져옴.
// 🆕 전체 테이블 목록 로드
useEffect(() => {
@@ -131,7 +141,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
setAllTables(normalizeConfigPanelTables(response.data));
}
} catch (error) {
console.error("전체 테이블 목록 로드 실패:", error);
@@ -213,12 +223,11 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
// 🆕 V2 컴포넌트 직접 감지 및 설정 패널 렌더링
if (componentId?.startsWith("v2-")) {
const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
"v2-input": require("@/components/v2/config-panels/InvFieldConfigPanel").InvFieldConfigPanel,
"v2-select": require("@/components/v2/config-panels/InvFieldConfigPanel").InvFieldConfigPanel,
// v2-date / v2-list / v2-repeater / v2-table-list 는 InvField 등 통합 — 하드코딩 매핑 제거 → ComponentRegistry fallback 사용
// V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. 하드코딩 매핑 제거.
// v2-date / v2-list / v2-repeater / v2-table-list 는 InvField 등 통합 — ComponentRegistry fallback 사용
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel,
"v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel,
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
// v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수.
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel")
@@ -236,32 +245,10 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
// 현재 화면의 테이블명 가져오기
const currentTableName = tables?.[0]?.tableName;
// DB input_type만 조회 (saved config와 분리하여 전달)
const colName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName;
const tblName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName;
const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined;
const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined;
// 컴포넌트별 추가 props
const extraProps: Record<string, any> = {};
const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName;
const resolvedColumnName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName;
if (componentId === "v2-input" || componentId === "v2-select") {
extraProps.componentType = componentId;
extraProps.inputType = dbInputType;
extraProps.tableName = resolvedTableName;
extraProps.columnName = resolvedColumnName;
extraProps.screenTableName = currentTableName || resolvedTableName;
extraProps.tables = allTables.map((table: any) => ({
tableName: table.tableName || table.table_name,
displayName: table.displayName || table.display_name || table.table_comment,
tableComment: table.tableComment || table.table_comment,
}));
}
if (componentId === "v2-input") {
extraProps.allComponents = allComponents;
}
if (componentId === "v2-list") {
extraProps.currentTableName = currentTableName;
}
@@ -314,8 +301,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
config={config}
onChange={handlePanelConfigChange}
onConfigChange={handlePanelConfigChange}
tables={tables}
allTables={allTables}
tables={normalizeConfigPanelTables(tables)}
allTables={allTables.length > 0 ? allTables : normalizeConfigPanelTables(tables)}
screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
tableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
currentTableName={currentTableName}
@@ -416,9 +403,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
"radio-basic",
"entity-search-input",
"autocomplete-search-input",
// 새로운 통합 입력 컴포넌트
"v2-input",
"v2-select",
// 새로운 통합 입력 컴포넌트 (옛 V2 입력/선택은 input canonical 로 흡수되어 목록에서 제거됨)
"input",
"v2-entity-select",
"v2-checkbox",
"v2-radio",
@@ -1,205 +0,0 @@
"use client";
import React, { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X } from "lucide-react";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent } from "@/types/screen";
import { toast } from "sonner";
import { apiClient, getFullImageUrl } from "@/lib/api/client";
export const ImageWidget: React.FC<
WebTypeComponentProps & { size?: { width?: number; height?: number }; style?: React.CSSProperties }
> = ({
component,
value,
onChange,
readonly = false,
isDesignMode = false, // 디자인 모드 여부
size, // props로 전달된 size
style: propStyle, // props로 전달된 style
}) => {
const widget = component as WidgetComponent;
const { required, style: widgetStyle } = widget;
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
// 이미지 URL 처리 (백엔드 서버 경로로 변환)
const rawImageUrl = value || (widget as any).value || "";
const imageUrl = rawImageUrl ? getFullImageUrl(rawImageUrl) : "";
// 🔧 컴포넌트 크기를 명시적으로 적용 (props.size 우선, 없으면 style에서 가져옴)
const effectiveSize = size || (widget as any).size || {};
const effectiveStyle = propStyle || widgetStyle || {};
const containerStyle: React.CSSProperties = {
width: effectiveSize.width ? `${effectiveSize.width}px` : effectiveStyle?.width || "100%",
height: effectiveSize.height ? `${effectiveSize.height}px` : effectiveStyle?.height || "100%",
};
// style에서 width, height 제거 (내부 요소용)
const filteredStyle = effectiveStyle ? { ...effectiveStyle, width: undefined, height: undefined } : {};
// 파일 선택 처리
const handleFileSelect = () => {
// 디자인 모드에서는 업로드 불가
if (readonly || isDesignMode) return;
fileInputRef.current?.click();
};
// 파일 업로드 처리
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 이미지 파일 검증
if (!file.type.startsWith("image/")) {
toast.error("이미지 파일만 업로드 가능합니다.");
return;
}
// 파일 크기 검증 (5MB)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
toast.error("파일 크기는 최대 5MB까지 가능합니다.");
return;
}
setUploading(true);
try {
// FormData 생성
const formData = new FormData();
formData.append("files", file);
formData.append("docType", "IMAGE");
formData.append("docTypeName", "이미지");
// 서버에 업로드 (axios 사용 - 인증 토큰 자동 포함)
const response = await apiClient.post("/files/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
if (response.data.success && response.data.files && response.data.files.length > 0) {
const uploadedFile = response.data.files[0];
const imageUrl = uploadedFile.filePath; // /uploads/company_*/2024/01/01/filename.jpg
onChange?.(imageUrl);
toast.success("이미지가 업로드되었습니다.");
} else {
throw new Error(response.data.message || "업로드 실패");
}
} catch (error: any) {
console.error("이미지 업로드 오류:", error);
const errorMessage = error.response?.data?.message || error.message || "이미지 업로드에 실패했습니다.";
toast.error(errorMessage);
} finally {
setUploading(false);
}
};
// 이미지 제거
const handleRemove = () => {
// 디자인 모드에서는 제거 불가
if (readonly || isDesignMode) return;
onChange?.("");
toast.success("이미지가 제거되었습니다.");
};
// 드래그 앤 드롭 처리
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// 디자인 모드에서는 드롭 불가
if (readonly || isDesignMode) return;
const file = e.dataTransfer.files[0];
if (!file) return;
// 파일 input에 파일 설정
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
if (fileInputRef.current) {
fileInputRef.current.files = dataTransfer.files;
handleFileChange({ target: fileInputRef.current } as any);
}
};
return (
<div className="flex h-full w-full flex-col" style={containerStyle}>
{imageUrl ? (
// 이미지 표시 모드
<div
className="group relative w-full flex-1 overflow-hidden rounded-lg border border-border bg-muted shadow-sm transition-all hover:shadow-md"
style={filteredStyle}
>
<img
src={imageUrl}
alt="업로드된 이미지"
className="h-full w-full object-contain"
onError={(e) => {
e.currentTarget.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f3f4f6'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%239ca3af'%3E이미지 로드 실패%3C/text%3E%3C/svg%3E";
}}
/>
{/* 호버 시 제거 버튼 */}
{!readonly && !isDesignMode && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<Button size="sm" variant="destructive" onClick={handleRemove} className="gap-2">
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
) : (
// 업로드 영역
<div
className={`group relative flex w-full flex-1 flex-col items-center justify-center rounded-lg border-2 border-dashed p-3 text-center shadow-sm transition-all duration-300 ${
isDesignMode
? "cursor-default border-border bg-muted"
: "cursor-pointer border-input bg-white hover:border-primary/60 hover:bg-primary/10/50 hover:shadow-md"
}`}
onClick={handleFileSelect}
onDragOver={handleDragOver}
onDrop={handleDrop}
style={filteredStyle}
>
{uploading ? (
<p className="text-xs font-medium text-primary"> ...</p>
) : readonly ? (
<p className="text-xs font-medium text-muted-foreground"> </p>
) : isDesignMode ? (
<p className="text-xs font-medium text-muted-foreground/70"> </p>
) : (
<p className="text-xs font-medium text-foreground transition-colors duration-300 group-hover:text-primary">
</p>
)}
</div>
)}
{/* 숨겨진 파일 input */}
<Input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/*"
onChange={handleFileChange}
disabled={readonly || uploading}
/>
{/* 필수 필드 경고 */}
{required && !imageUrl && <div className="text-xs text-destructive">* </div>}
</div>
);
};
ImageWidget.displayName = "ImageWidget";
@@ -11,7 +11,7 @@ import { TextareaWidget } from "./TextareaWidget";
import { CheckboxWidget } from "./CheckboxWidget";
import { RadioWidget } from "./RadioWidget";
import { FileWidget } from "./FileWidget";
import { ImageWidget } from "./ImageWidget";
// ImageWidget 본체는 Phase D.6 에서 삭제됨 — canonical input 의 file 분기로 흡수.
import { CodeWidget } from "./CodeWidget";
import { EntityWidget } from "./EntityWidget";
import { RatingWidget } from "./RatingWidget";
@@ -25,7 +25,7 @@ export { TextareaWidget } from "./TextareaWidget";
export { CheckboxWidget } from "./CheckboxWidget";
export { RadioWidget } from "./RadioWidget";
export { FileWidget } from "./FileWidget";
export { ImageWidget } from "./ImageWidget";
// ImageWidget — Phase D.6 폐기
export { CodeWidget } from "./CodeWidget";
export { EntityWidget } from "./EntityWidget";
export { RatingWidget } from "./RatingWidget";
@@ -49,8 +49,7 @@ export const getWidgetComponentByName = (componentName: string): React.Component
return RadioWidget;
case "FileWidget":
return FileWidget;
case "ImageWidget":
return ImageWidget;
// case "ImageWidget" — Phase D.6 폐기. canonical input 의 file 분기로 흡수.
case "CodeWidget":
return CodeWidget;
case "EntityWidget":
@@ -109,11 +108,7 @@ export const getWidgetComponentByWebType = (webType: string): React.ComponentTyp
case "attachment":
return FileWidget;
case "image":
case "img":
case "picture":
case "photo":
return ImageWidget;
// image / img / picture / photo — Phase D.6 폐기. canonical input 의 file 분기로 흡수.
case "code":
case "script":
@@ -165,7 +160,7 @@ export const WebTypeComponents: Record<string, React.ComponentType<WebTypeCompon
checkbox: CheckboxWidget,
radio: RadioWidget,
file: FileWidget,
image: ImageWidget,
// image: ImageWidget — Phase D.6 폐기
code: CodeWidget,
entity: EntityWidget,
rating: RatingWidget,
+2 -83
View File
@@ -254,89 +254,8 @@ export function DynamicConfigPanel({
* ( )
*/
export const COMMON_SCHEMAS = {
// V2Input 기본 스키마
V2Input: {
type: "object" as const,
properties: {
type: {
type: "string" as const,
enum: ["text", "number", "password", "slider", "color", "button"],
default: "text",
title: "입력 타입",
},
format: {
type: "string" as const,
enum: ["none", "email", "tel", "url", "currency", "biz_no"],
default: "none",
title: "형식",
},
placeholder: {
type: "string" as const,
title: "플레이스홀더",
},
min: {
type: "number" as const,
title: "최소값",
description: "숫자 타입 전용",
},
max: {
type: "number" as const,
title: "최대값",
description: "숫자 타입 전용",
},
step: {
type: "number" as const,
title: "증가 단위",
},
},
},
// V2Select 기본 스키마
V2Select: {
type: "object" as const,
properties: {
mode: {
type: "string" as const,
enum: ["dropdown", "radio", "check", "tag", "toggle", "swap"],
default: "dropdown",
title: "표시 모드",
},
source: {
type: "string" as const,
enum: ["static", "code", "db", "api", "entity"],
default: "static",
title: "데이터 소스",
},
codeGroup: {
type: "string" as const,
title: "코드 그룹",
description: "source가 code일 때 사용",
},
searchable: {
type: "boolean" as const,
default: false,
title: "검색 가능",
},
multiple: {
type: "boolean" as const,
default: false,
title: "다중 선택",
},
maxSelect: {
type: "number" as const,
title: "최대 선택 수",
},
cascading: {
type: "object" as const,
title: "연쇄 관계",
properties: {
parentField: { type: "string" as const, title: "부모 필드" },
filterColumn: { type: "string" as const, title: "필터 컬럼" },
clearOnChange: { type: "boolean" as const, default: true, title: "부모 변경시 초기화" },
},
},
},
},
// 옛 입력/선택 기본 스키마는 Phase D.3 에서 제거됨.
// canonical 스키마는 lib/schemas/componentConfig.ts (`input`) 와 InvFieldConfigPanel 의 brumb 가 진실의 원천.
} satisfies Record<string, V2ConfigSchema>;
export default DynamicConfigPanel;
+5 -19
View File
@@ -10,22 +10,17 @@
import React, { forwardRef, useMemo } from "react";
import {
V2ComponentProps,
isV2Input,
isV2Select,
isV2Text,
isV2Media,
isV2List,
isV2Layout,
isV2Group,
isV2Biz,
isV2Hierarchy,
} from "@/types/v2-components";
import { V2Input } from "./V2Input";
import { V2Select } from "./V2Select";
// 옛 입력/선택 import 는 Phase D.3 에서 제거. V2Media 는 Phase D.5 에서 제거 — canonical input 으로 흡수.
import { V2List } from "./V2List";
import { V2Layout } from "./V2Layout";
import { V2Group } from "./V2Group";
import { V2Media } from "./V2Media";
import { V2Biz } from "./V2Biz";
import { V2Hierarchy } from "./V2Hierarchy";
@@ -41,27 +36,18 @@ export const V2ComponentRenderer = forwardRef<HTMLDivElement, V2ComponentRendere
({ props, className }, ref) => {
const component = useMemo(() => {
// 타입 가드를 사용하여 적절한 컴포넌트 렌더링
if (isV2Input(props)) {
return <V2Input {...props} />;
}
if (isV2Select(props)) {
return <V2Select {...props} />;
}
// (옛 입력/선택 분기 — Phase D.3 에서 제거. canonical `input` 으로 흡수됨)
if (isV2Text(props)) {
// V2Text는 V2Inputtextarea 모드로 대체
// 필요시 별도 구현
// V2Text canonical `input` (textarea 모드) 으로 대체. 필요시 별도 구현
return (
<div className="p-2 border rounded text-sm text-muted-foreground">
V2Text (V2Input textarea )
V2Text (canonical `input` textarea )
</div>
);
}
if (isV2Media(props)) {
return <V2Media {...props} />;
}
// V2Media — Phase D.5 폐기. canonical input 의 file 분기로 흡수.
if (isV2List(props)) {
return <V2List {...props} />;
+12 -683
View File
@@ -12,57 +12,31 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ArrowLeft, Code, Eye, Zap } from "lucide-react";
import { ArrowLeft } from "lucide-react";
// V2 컴포넌트들
import { V2Input } from "./V2Input";
import { V2Select } from "./V2Select";
// V2 컴포넌트들 (옛 입력/선택은 Phase D.3 에서 폐기 — canonical `input` 으로 흡수됨)
import { V2List } from "./V2List";
import { V2Layout } from "./V2Layout";
import { V2Group } from "./V2Group";
import { V2Media } from "./V2Media";
// V2Media — Phase D.5 폐기. canonical input 의 file 분기로 흡수.
import { V2Biz } from "./V2Biz";
import { V2Hierarchy } from "./V2Hierarchy";
// 조건부 로직
import { V2FormProvider, useV2Form } from "./V2FormContext";
import { ConditionalConfigPanel } from "./ConditionalConfigPanel";
// 조건부 로직 데모는 Phase D.3 에서 폐기 (옛 입력/선택 의존이 핵심이었음)
// 타입
import { HierarchyNode, ConditionalConfig } from "@/types/v2-components";
import { HierarchyNode } from "@/types/v2-components";
interface V2ComponentsDemoProps {
onBack?: () => void;
}
export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
const [activeTab, setActiveTab] = useState("conditional");
const [activeTab, setActiveTab] = useState("list");
// 데모용 상태
const [inputValues, setInputValues] = useState({
text: "",
number: 50,
password: "",
slider: 30,
color: "#3b82f6",
});
const [selectValues, setSelectValues] = useState({
dropdown: "",
radio: "",
check: [] as string[],
tag: [] as string[],
toggle: "false",
});
// 샘플 데이터
const sampleOptions = [
{ value: "option1", label: "옵션 1" },
{ value: "option2", label: "옵션 2" },
{ value: "option3", label: "옵션 3" },
{ value: "option4", label: "옵션 4" },
];
// 데모용 상태 (옛 입력/선택 데모 state — Phase D.3 에서 제거됨)
// 샘플 데이터
const sampleTableData = [
{ id: 1, name: "홍길동", email: "hong@test.com", status: "active", date: "2024-01-15" },
{ id: 2, name: "김철수", email: "kim@test.com", status: "inactive", date: "2024-02-20" },
@@ -117,211 +91,16 @@ export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
{/* 탭 컨텐츠 */}
<div className="flex-1 overflow-auto p-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-5 lg:grid-cols-9">
<TabsTrigger value="conditional" className="gap-1 text-amber-600">
<Zap className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="input">Input</TabsTrigger>
<TabsTrigger value="select">Select</TabsTrigger>
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6">
<TabsTrigger value="list">List</TabsTrigger>
<TabsTrigger value="layout">Layout</TabsTrigger>
<TabsTrigger value="group" className="hidden lg:flex">Group</TabsTrigger>
<TabsTrigger value="media" className="hidden lg:flex">Media</TabsTrigger>
<TabsTrigger value="biz" className="hidden lg:flex">Biz</TabsTrigger>
<TabsTrigger value="hierarchy" className="hidden lg:flex">Hierarchy</TabsTrigger>
</TabsList>
{/* 조건부 동작 데모 탭 */}
<TabsContent value="conditional" className="mt-6">
<ConditionalDemo />
</TabsContent>
{/* V2Input 탭 */}
<TabsContent value="input" className="mt-6">
<Card>
<CardHeader>
<CardTitle>V2Input</CardTitle>
<CardDescription>
- text, number, password, slider, color, button
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Text Input */}
<V2Input
id="demo-text"
label="텍스트 입력"
v2Type="V2Input"
config={{ type: "text", placeholder: "텍스트를 입력하세요" }}
value={inputValues.text}
onChange={(v) => setInputValues({ ...inputValues, text: String(v) })}
/>
{/* Number Input */}
<V2Input
id="demo-number"
label="숫자 입력"
required
v2Type="V2Input"
config={{ type: "number", min: 0, max: 100, step: 5 }}
value={inputValues.number}
onChange={(v) => setInputValues({ ...inputValues, number: Number(v) })}
/>
{/* Password Input */}
<V2Input
id="demo-password"
label="비밀번호"
v2Type="V2Input"
config={{ type: "password", placeholder: "비밀번호 입력" }}
value={inputValues.password}
onChange={(v) => setInputValues({ ...inputValues, password: String(v) })}
/>
{/* Slider Input */}
<V2Input
id="demo-slider"
label="슬라이더"
v2Type="V2Input"
config={{ type: "slider", min: 0, max: 100, step: 10 }}
value={inputValues.slider}
onChange={(v) => setInputValues({ ...inputValues, slider: Number(v) })}
/>
{/* Color Input */}
<V2Input
id="demo-color"
label="색상 선택"
v2Type="V2Input"
config={{ type: "color" }}
value={inputValues.color}
onChange={(v) => setInputValues({ ...inputValues, color: String(v) })}
/>
{/* Button */}
<V2Input
id="demo-button"
label="버튼"
v2Type="V2Input"
config={{
type: "button",
button_text: "클릭하세요",
button_variant: "default"
}}
/>
</div>
<Separator />
<div className="p-4 bg-muted/50 rounded-lg">
<h4 className="text-sm font-medium mb-2"> :</h4>
<pre className="text-xs overflow-auto">
{JSON.stringify(inputValues, null, 2)}
</pre>
</div>
</CardContent>
</Card>
</TabsContent>
{/* V2Select 탭 */}
<TabsContent value="select" className="mt-6">
<Card>
<CardHeader>
<CardTitle>V2Select</CardTitle>
<CardDescription>
- dropdown, radio, check, tag, toggle, swap
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Dropdown */}
<V2Select
id="demo-dropdown"
label="드롭다운"
v2Type="V2Select"
config={{
mode: "dropdown",
source: "static",
options: sampleOptions,
searchable: true
}}
value={selectValues.dropdown}
onChange={(v) => setSelectValues({ ...selectValues, dropdown: String(v) })}
/>
{/* Radio */}
<V2Select
id="demo-radio"
label="라디오 버튼"
v2Type="V2Select"
config={{
mode: "radio",
source: "static",
options: sampleOptions
}}
value={selectValues.radio}
onChange={(v) => setSelectValues({ ...selectValues, radio: String(v) })}
/>
{/* Checkbox */}
<V2Select
id="demo-check"
label="체크박스 (다중선택)"
v2Type="V2Select"
config={{
mode: "check",
source: "static",
options: sampleOptions,
max_select: 3
}}
value={selectValues.check}
onChange={(v) => setSelectValues({ ...selectValues, check: v as string[] })}
/>
{/* Tag */}
<V2Select
id="demo-tag"
label="태그 선택"
v2Type="V2Select"
config={{
mode: "tag",
source: "static",
options: sampleOptions
}}
value={selectValues.tag}
onChange={(v) => setSelectValues({ ...selectValues, tag: v as string[] })}
/>
{/* Toggle */}
<V2Select
id="demo-toggle"
label="토글"
v2Type="V2Select"
config={{
mode: "toggle",
source: "static",
options: [
{ value: "false", label: "비활성" },
{ value: "true", label: "활성" }
]
}}
value={selectValues.toggle}
onChange={(v) => setSelectValues({ ...selectValues, toggle: String(v) })}
/>
</div>
<Separator />
<div className="p-4 bg-muted/50 rounded-lg">
<h4 className="text-sm font-medium mb-2"> :</h4>
<pre className="text-xs overflow-auto">
{JSON.stringify(selectValues, null, 2)}
</pre>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 조건부 동작 데모 탭 — Phase D.3 에서 폐기 (옛 입력/선택 의존) */}
{/* 옛 입력/선택 탭 — Phase D.3 에서 폐기. canonical `input` 데모는 별도 화면에서 확인 */}
{/* V2List 탭 */}
<TabsContent value="list" className="mt-6">
@@ -517,36 +296,7 @@ export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
</Card>
</TabsContent>
{/* V2Media 탭 */}
<TabsContent value="media" className="mt-6">
<Card>
<CardHeader>
<CardTitle>V2Media</CardTitle>
<CardDescription>
- file, image, video, audio
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* File Upload */}
<V2Media
id="demo-file"
label="파일 업로드"
v2Type="V2Media"
config={{ type: "file", multiple: true }}
/>
{/* Image Upload */}
<V2Media
id="demo-image"
label="이미지 업로드"
v2Type="V2Media"
config={{ type: "image", preview: true }}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* V2Media 탭 — Phase D.5 에서 폐기. canonical input 의 file 분기로 흡수 */}
{/* V2Biz 탭 */}
<TabsContent value="biz" className="mt-6">
@@ -623,424 +373,3 @@ export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
export default V2ComponentsDemo;
// ===== 조건부 동작 데모 컴포넌트 =====
/**
*
*
* :
* 1.
* 2. VIP VIP
* 3. ()
*/
function ConditionalDemo() {
return (
<V2FormProvider
initialValues={{
contractType: "",
isVip: false,
country: "",
city: "",
discountRate: 0,
employeeCount: 0,
startDate: "",
endDate: "",
}}
>
<ConditionalDemoContent />
</V2FormProvider>
);
}
function ConditionalDemoContent() {
const { formData, setValue, evaluateCondition } = useV2Form();
// 국가별 도시 데이터
const cityOptions: Record<string, Array<{ value: string; label: string }>> = {
korea: [
{ value: "seoul", label: "서울" },
{ value: "busan", label: "부산" },
{ value: "incheon", label: "인천" },
{ value: "daegu", label: "대구" },
],
japan: [
{ value: "tokyo", label: "도쿄" },
{ value: "osaka", label: "오사카" },
{ value: "kyoto", label: "교토" },
],
usa: [
{ value: "newyork", label: "뉴욕" },
{ value: "la", label: "로스앤젤레스" },
{ value: "chicago", label: "시카고" },
],
};
// 현재 선택된 국가의 도시 옵션
const currentCountry = formData.country as string;
const availableCities = currentCountry ? cityOptions[currentCountry] || [] : [];
// 조건부 설정
const showB2BFields: ConditionalConfig = {
enabled: true,
field: "contractType",
operator: "=",
value: "b2b",
action: "show",
};
const showB2CFields: ConditionalConfig = {
enabled: true,
field: "contractType",
operator: "=",
value: "b2c",
action: "show",
};
const showDiscountField: ConditionalConfig = {
enabled: true,
field: "isVip",
operator: "=",
value: true,
action: "show",
};
// 조건 평가
const b2bState = evaluateCondition("b2bFields", showB2BFields);
const b2cState = evaluateCondition("b2cFields", showB2CFields);
const discountState = evaluateCondition("discountRate", showDiscountField);
return (
<div className="space-y-6">
{/* 시나리오 1: 계약 유형에 따른 필드 표시 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> 1: 계약 </CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 계약 유형 선택 */}
<V2Select
id="contractType"
label="계약 유형"
required
v2Type="V2Select"
config={{
mode: "radio",
source: "static",
options: [
{ value: "b2b", label: "B2B (기업간 거래)" },
{ value: "b2c", label: "B2C (소비자 거래)" },
],
}}
value={formData.contractType as string}
onChange={(v) => setValue("contractType", v)}
/>
{/* B2B 전용 필드 */}
{b2bState.visible && (
<div className="p-4 bg-primary/10 rounded-lg space-y-4 border-l-4 border-primary">
<p className="text-sm font-medium text-primary">B2B </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<V2Input
id="companyName"
label="거래처 회사명"
required
v2Type="V2Input"
config={{ type: "text", placeholder: "회사명 입력" }}
value={formData.companyName as string || ""}
onChange={(v) => setValue("companyName", v)}
/>
<V2Input
id="employeeCount"
label="직원 수"
v2Type="V2Input"
config={{ type: "number", min: 1, max: 100000 }}
value={formData.employeeCount as number || 0}
onChange={(v) => setValue("employeeCount", v)}
/>
</div>
</div>
)}
{/* B2C 전용 필드 */}
{b2cState.visible && (
<div className="p-4 bg-emerald-50 rounded-lg space-y-4 border-l-4 border-emerald-500">
<p className="text-sm font-medium text-emerald-700">B2C </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<V2Input
id="customerName"
label="고객명"
required
v2Type="V2Input"
config={{ type: "text", placeholder: "고객명 입력" }}
value={formData.customerName as string || ""}
onChange={(v) => setValue("customerName", v)}
/>
<V2Input
id="phone"
label="연락처"
v2Type="V2Input"
config={{ type: "text", placeholder: "010-0000-0000" }}
value={formData.phone as string || ""}
onChange={(v) => setValue("phone", v)}
/>
</div>
</div>
)}
</CardContent>
</Card>
{/* 시나리오 2: VIP 여부에 따른 필드 활성화 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> 2: 조건부 </CardTitle>
<CardDescription>
VIP
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<V2Select
id="isVip"
label="VIP 고객 여부"
v2Type="V2Select"
config={{
mode: "toggle",
source: "static",
options: [
{ value: "false", label: "일반" },
{ value: "true", label: "VIP" },
],
}}
value={String(formData.isVip)}
onChange={(v) => setValue("isVip", v === "true")}
/>
</div>
{/* VIP 전용 할인율 필드 */}
{discountState.visible && (
<div className="p-4 bg-amber-50 rounded-lg border-l-4 border-yellow-500">
<p className="text-sm font-medium text-yellow-700 mb-3">VIP </p>
<V2Input
id="discountRate"
label="할인율 (%)"
v2Type="V2Input"
config={{
type: "slider",
min: 0,
max: 50,
step: 5,
}}
value={formData.discountRate as number || 0}
onChange={(v) => setValue("discountRate", v)}
/>
<p className="text-xs text-muted-foreground mt-2">
: {(formData.discountRate as number) || 0}%
</p>
</div>
)}
</CardContent>
</Card>
{/* 시나리오 3: 연쇄 드롭다운 (국가 → 도시) */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> 3: 연쇄 </CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 국가 선택 */}
<V2Select
id="country"
label="국가"
required
v2Type="V2Select"
config={{
mode: "dropdown",
source: "static",
options: [
{ value: "korea", label: "대한민국" },
{ value: "japan", label: "일본" },
{ value: "usa", label: "미국" },
],
searchable: true,
}}
value={formData.country as string}
onChange={(v) => {
setValue("country", v);
setValue("city", ""); // 국가 변경 시 도시 초기화
}}
/>
{/* 도시 선택 (국가에 따라 옵션 변경) */}
<V2Select
id="city"
label="도시"
required
disabled={!currentCountry}
v2Type="V2Select"
config={{
mode: "dropdown",
source: "static",
options: availableCities,
searchable: true,
}}
value={formData.city as string}
onChange={(v) => setValue("city", v)}
/>
</div>
{!currentCountry && (
<p className="text-sm text-muted-foreground">
</p>
)}
</CardContent>
</Card>
{/* 조건부 설정 UI 데모 */}
<ConditionalConfigUIDemo formData={formData} />
{/* 현재 폼 데이터 표시 (하단으로 이동) */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<div className="p-4 bg-muted/50 rounded-lg">
<pre className="text-xs overflow-auto whitespace-pre-wrap">
{JSON.stringify(formData, null, 2)}
</pre>
</div>
</CardContent>
</Card>
</div>
);
}
/**
* UI
*
* UI로
*/
function ConditionalConfigUIDemo({ formData }: { formData: Record<string, unknown> }) {
const [demoConfig, setDemoConfig] = useState<ConditionalConfig | undefined>(undefined);
const { evaluateCondition } = useV2Form();
// 데모용 필드 목록 (현재 폼의 필드들)
const availableFields = [
{ id: "contractType", label: "계약 유형", type: "select", options: [
{ value: "b2b", label: "B2B" },
{ value: "b2c", label: "B2C" },
]},
{ id: "isVip", label: "VIP 여부", type: "checkbox" },
{ id: "country", label: "국가", type: "select", options: [
{ value: "korea", label: "대한민국" },
{ value: "japan", label: "일본" },
{ value: "usa", label: "미국" },
]},
{ id: "discountRate", label: "할인율", type: "number" },
{ id: "employeeCount", label: "직원 수", type: "number" },
];
// 현재 설정으로 조건 평가
const conditionResult = demoConfig ? evaluateCondition("demoField", demoConfig) : { visible: true, disabled: false };
return (
<Card className="border-orange-200 bg-amber-50/30">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Zap className="h-5 w-5 text-amber-500" />
UI
</CardTitle>
<CardDescription>
UI로
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 왼쪽: 설정 UI */}
<div className="space-y-4">
<h4 className="text-sm font-medium"> ( UI)</h4>
<div className="border rounded-lg p-4 bg-white">
<ConditionalConfigPanel
config={demoConfig}
onChange={setDemoConfig}
availableFields={availableFields}
currentComponentId="demoField"
/>
</div>
</div>
{/* 오른쪽: 미리보기 */}
<div className="space-y-4">
<h4 className="text-sm font-medium"> </h4>
<div className="border rounded-lg p-4 bg-white space-y-4">
{/* 조건 평가 결과 */}
<div className="space-y-2">
<p className="text-xs text-muted-foreground"> :</p>
<div className="flex gap-4">
<Badge variant={conditionResult.visible ? "default" : "secondary"}>
{conditionResult.visible ? "표시됨" : "숨겨짐"}
</Badge>
<Badge variant={conditionResult.disabled ? "destructive" : "outline"}>
{conditionResult.disabled ? "비활성화" : "활성화"}
</Badge>
</div>
</div>
<Separator />
{/* 대상 필드 미리보기 */}
<div className="space-y-2">
<p className="text-xs text-muted-foreground"> :</p>
{conditionResult.visible ? (
<V2Input
id="demoTargetField"
label="테스트 입력 필드"
disabled={conditionResult.disabled}
v2Type="V2Input"
config={{ type: "text", placeholder: "이 필드가 조건에 따라 표시/숨김됩니다" }}
/>
) : (
<div className="p-4 border-2 border-dashed rounded-lg text-center text-muted-foreground text-sm">
( )
</div>
)}
</div>
<Separator />
{/* 생성된 JSON */}
<div className="space-y-2">
<p className="text-xs text-muted-foreground"> JSON:</p>
<div className="p-3 bg-slate-900 rounded text-slate-100">
<pre className="text-[10px] overflow-auto">
{demoConfig ? JSON.stringify(demoConfig, null, 2) : "(설정 없음)"}
</pre>
</div>
</div>
</div>
</div>
</div>
{/* 안내 메시지 */}
<div className="mt-4 p-3 bg-primary/10 rounded-lg">
<p className="text-xs text-primary">
"조건부 표시" .
.
</p>
</div>
</CardContent>
</Card>
);
}
File diff suppressed because it is too large Load Diff
-979
View File
@@ -1,979 +0,0 @@
"use client";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
/**
* V2Media
*
* ( FileUploadComponent )
* - file: 파일
* - image: 이미지 /
* - video: 비디오
* - audio: 오디오
*
* :
* - FileViewerModal / FileManagerModal ()
* -
* - (/ )
* -
* - /
* - DB에서
*/
import React, { forwardRef, useCallback, useRef, useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { V2MediaProps } from "@/types/v2-components";
import {
Upload,
X,
File,
Image as ImageIcon,
Video,
Music,
Eye,
Download,
Trash2,
Plus,
FileText,
Archive,
Presentation,
FileImage,
FileVideo,
FileAudio,
} from "lucide-react";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
import { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file";
import { GlobalFileManager } from "@/lib/api/globalFile";
import { formatFileSize } from "@/lib/utils";
import { useAuth } from "@/hooks/useAuth";
// 레거시 모달 컴포넌트 import
import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal";
import { FileManagerModal } from "@/lib/registry/components/file-upload/FileManagerModal";
import type { FileInfo, FileUploadConfig } from "@/lib/registry/components/file-upload/types";
/**
*
*/
const getFileIcon = (extension: string) => {
const ext = extension.toLowerCase().replace(".", "");
if (["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext)) {
return <FileImage className="text-primary h-6 w-6" />;
}
if (["mp4", "avi", "mov", "wmv", "flv", "webm"].includes(ext)) {
return <FileVideo className="h-6 w-6 text-purple-500" />;
}
if (["mp3", "wav", "flac", "aac", "ogg"].includes(ext)) {
return <FileAudio className="h-6 w-6 text-emerald-500" />;
}
if (["pdf"].includes(ext)) {
return <FileText className="text-destructive h-6 w-6" />;
}
if (["doc", "docx", "hwp", "hwpx", "pages"].includes(ext)) {
return <FileText className="text-primary h-6 w-6" />;
}
if (["xls", "xlsx", "hcell", "numbers"].includes(ext)) {
return <FileText className="h-6 w-6 text-emerald-600" />;
}
if (["ppt", "pptx", "hanshow", "keynote"].includes(ext)) {
return <Presentation className="h-6 w-6 text-amber-500" />;
}
if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) {
return <Archive className="text-muted-foreground h-6 w-6" />;
}
return <File className="text-muted-foreground/70 h-6 w-6" />;
};
/**
* V2 ( )
*/
export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>((props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
formData,
columnName,
tableName,
onFormDataChange,
isDesignMode = false,
isInteractive = true,
onUpdate,
...restProps
} = props;
// 인증 정보
const { user } = useAuth();
// config 기본값
const config = configProp || { type: "file" as const };
const mediaType = config.type || "file";
// 파일 상태
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
const [uploadStatus, setUploadStatus] = useState<"idle" | "uploading" | "success" | "error">("idle");
const [dragOver, setDragOver] = useState(false);
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
// 모달 상태
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [isFileManagerOpen, setIsFileManagerOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 레코드 모드 판단
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith("temp_"));
const recordTableName = formData?.tableName || tableName;
const recordId = formData?.id;
// 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments
const effectiveColumnName = columnName || id || "attachments";
// 레코드용 targetObjid 생성
const getRecordTargetObjid = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
return `${recordTableName}:${recordId}:${effectiveColumnName}`;
}
return null;
}, [isRecordMode, recordTableName, recordId, effectiveColumnName]);
// 레코드별 고유 키 생성
const getUniqueKey = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
return `v2media_${recordTableName}_${recordId}_${id}`;
}
return `v2media_${id}`;
}, [isRecordMode, recordTableName, recordId, id]);
// 레코드 ID 변경 시 파일 목록 초기화
const prevRecordIdRef = useRef<any>(null);
useEffect(() => {
if (prevRecordIdRef.current !== recordId) {
prevRecordIdRef.current = recordId;
if (isRecordMode) {
setUploadedFiles([]);
}
}
}, [recordId, isRecordMode]);
// 컴포넌트 마운트 시 localStorage에서 파일 복원
useEffect(() => {
if (!id) return;
try {
const backupKey = getUniqueKey();
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles) {
const parsedFiles = JSON.parse(backupFiles);
if (parsedFiles.length > 0) {
setUploadedFiles(parsedFiles);
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[backupKey]: parsedFiles,
};
}
}
}
} catch (e) {
console.warn("파일 복원 실패:", e);
}
}, [id, getUniqueKey, recordId]);
// DB에서 파일 목록 로드
const loadComponentFiles = useCallback(async () => {
if (!id) return false;
try {
let screenId = formData?.screen_id;
if (!screenId && typeof window !== "undefined") {
const pathname = window.location.pathname;
const screenMatch = pathname.match(/\/screens\/(\d+)/);
if (screenMatch) {
screenId = parseInt(screenMatch[1]);
}
}
if (!screenId && isDesignMode) {
screenId = 999999;
}
if (!screenId) {
screenId = 0;
}
const params = {
screenId,
componentId: id,
tableName: recordTableName || formData?.tableName || tableName,
recordId: recordId || formData?.id,
columnName: effectiveColumnName,
};
const response = await getComponentFiles(params);
if (response.success) {
const formattedFiles = response.totalFiles.map((file: any) => ({
objid: file.objid || file.id,
savedFileName: file.savedFileName || file.saved_file_name,
realFileName: file.realFileName || file.real_file_name,
fileSize: file.fileSize || file.file_size,
fileExt: file.fileExt || file.file_ext,
regdate: file.regdate,
status: file.status || "ACTIVE",
uploadedAt: file.uploadedAt || new Date().toISOString(),
targetObjid: file.targetObjid || file.target_objid,
filePath: file.filePath || file.file_path,
...file,
}));
// localStorage와 병합
let finalFiles = formattedFiles;
const uniqueKey = getUniqueKey();
try {
const backupFiles = localStorage.getItem(uniqueKey);
if (backupFiles) {
const parsedBackupFiles = JSON.parse(backupFiles);
const serverObjIds = new Set(formattedFiles.map((f: any) => f.objid));
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
finalFiles = [...formattedFiles, ...additionalFiles];
}
} catch (e) {
console.warn("파일 병합 오류:", e);
}
setUploadedFiles(finalFiles);
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[uniqueKey]: finalFiles,
};
GlobalFileManager.registerFiles(finalFiles, {
uploadPage: window.location.pathname,
componentId: id,
screenId: formData?.screen_id,
recordId: recordId,
});
try {
localStorage.setItem(uniqueKey, JSON.stringify(finalFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
}
}
return true;
}
} catch (error) {
console.error("파일 조회 오류:", error);
}
return false;
}, [
id,
tableName,
columnName,
formData?.screen_id,
formData?.tableName,
formData?.id,
getUniqueKey,
recordId,
isRecordMode,
recordTableName,
effectiveColumnName,
isDesignMode,
]);
// 파일 동기화
useEffect(() => {
loadComponentFiles();
}, [loadComponentFiles]);
// 전역 상태 변경 감지
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
const { componentId, files, isRestore } = event.detail;
if (componentId === id) {
setUploadedFiles(files);
try {
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(files));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
}
}
};
if (typeof window !== "undefined") {
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
return () => {
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
};
}
}, [id, getUniqueKey]);
// 파일 업로드 처리
const handleFileUpload = useCallback(
async (files: File[]) => {
if (!files.length) return;
// 중복 체크
const existingFileNames = uploadedFiles.map((f) => f.realFileName.toLowerCase());
const duplicates: string[] = [];
const uniqueFiles: File[] = [];
files.forEach((file) => {
const fileName = file.name.toLowerCase();
if (existingFileNames.includes(fileName)) {
duplicates.push(file.name);
} else {
uniqueFiles.push(file);
}
});
if (duplicates.length > 0) {
toast.error(`중복된 파일: ${duplicates.join(", ")}`);
if (uniqueFiles.length === 0) return;
toast.info(`${uniqueFiles.length}개의 새로운 파일만 업로드합니다.`);
}
const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files;
setUploadStatus("uploading");
toast.loading("파일 업로드 중...", { id: "file-upload" });
try {
const effectiveTableName = recordTableName || formData?.tableName || tableName || "default_table";
const effectiveRecordId = recordId || formData?.id;
let screenId = formData?.screen_id;
if (!screenId && typeof window !== "undefined") {
const pathname = window.location.pathname;
const screenMatch = pathname.match(/\/screens\/(\d+)/);
if (screenMatch) {
screenId = parseInt(screenMatch[1]);
}
}
let targetObjid;
const effectiveIsRecordMode =
isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith("temp_"));
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
} else if (screenId) {
targetObjid = `screen_files:${screenId}:${id}:${effectiveColumnName}`;
} else {
targetObjid = `temp_${id}`;
}
const userCompanyCode = user?.company_code || (window as any).__user__?.company_code;
const finalLinkedTable = effectiveIsRecordMode
? effectiveTableName
: formData?.linkedTable || effectiveTableName;
const uploadData = {
autoLink: formData?.autoLink || true,
linkedTable: finalLinkedTable,
recordId: effectiveRecordId || `temp_${id}`,
columnName: effectiveColumnName,
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
docType: config?.docType || "DOCUMENT",
docTypeName: config?.docTypeName || "일반 문서",
companyCode: userCompanyCode,
tableName: effectiveTableName,
fieldName: effectiveColumnName,
targetObjid: targetObjid,
isRecordMode: effectiveIsRecordMode,
};
const response = await uploadFiles({
files: filesToUpload,
...uploadData,
});
if (response.success) {
const fileData = response.files || (response as any).data || [];
if (fileData.length === 0) {
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
}
const newFiles = fileData.map((file: any) => ({
objid: file.objid || file.id,
savedFileName: file.saved_file_name || file.savedFileName,
realFileName: file.real_file_name || file.realFileName || file.name,
fileSize: file.file_size || file.fileSize || file.size,
fileExt: file.file_ext || file.fileExt || file.extension,
filePath: file.file_path || file.filePath || file.path,
docType: file.doc_type || file.docType,
docTypeName: file.doc_type_name || file.docTypeName,
targetObjid: file.target_objid || file.targetObjid,
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
company_code: file.company_code,
writer: file.writer,
regdate: file.regdate,
status: file.status || "ACTIVE",
uploadedAt: new Date().toISOString(),
...file,
}));
const updatedFiles = [...uploadedFiles, ...newFiles];
setUploadedFiles(updatedFiles);
setUploadStatus("success");
// localStorage 백업
try {
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
}
// 전역 상태 업데이트
if (typeof window !== "undefined") {
const globalFileState = (window as any).globalFileState || {};
const uniqueKey = getUniqueKey();
globalFileState[uniqueKey] = updatedFiles;
(window as any).globalFileState = globalFileState;
GlobalFileManager.registerFiles(newFiles, {
uploadPage: window.location.pathname,
componentId: id,
screenId: formData?.screen_id,
recordId: recordId,
});
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: id,
uniqueKey: uniqueKey,
recordId: recordId,
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now(),
},
});
window.dispatchEvent(syncEvent);
}
// 부모 컴포넌트 업데이트
if (onUpdate) {
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: Date.now(),
});
}
// onChange 콜백 (objid 배열 또는 단일 값)
const fileIds = updatedFiles.map((f) => f.objid);
const finalValue = config.multiple ? fileIds : fileIds[0] || "";
const targetColumn = columnName || effectiveColumnName;
console.log("📤 [V2Media] 파일 업로드 완료 - 값 전달:", {
columnName: targetColumn,
fileIds,
finalValue,
hasOnChange: !!onChange,
hasOnFormDataChange: !!onFormDataChange,
});
if (onChange) {
onChange(finalValue);
}
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식
if (onFormDataChange && targetColumn) {
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
// 복수 파일: 콤마 구분 문자열로 전달
const formValue = config.multiple ? fileIds.join(",") : fileIds[0] || "";
console.log("📝 [V2Media] formData 업데이트:", {
columnName: targetColumn,
fileIds,
formValue,
isMultiple: config.multiple,
isRecordMode: effectiveIsRecordMode,
});
// (fieldName: string, value: any) 형식으로 호출
onFormDataChange(targetColumn, formValue);
}
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== "undefined") {
const refreshEvent = new CustomEvent("refreshFileStatus", {
detail: {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: targetColumn,
targetObjid: targetObjid,
fileCount: updatedFiles.length,
},
});
window.dispatchEvent(refreshEvent);
}
toast.dismiss("file-upload");
toast.success(`${newFiles.length}개 파일 업로드 완료`);
} else {
throw new Error(response.message || (response as any).error || "파일 업로드 실패");
}
} catch (error) {
console.error("파일 업로드 오류:", error);
setUploadStatus("error");
toast.dismiss("file-upload");
toast.error(`업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
}
},
[
config,
uploadedFiles,
onChange,
id,
getUniqueKey,
recordId,
isRecordMode,
recordTableName,
effectiveColumnName,
tableName,
onUpdate,
onFormDataChange,
user,
columnName,
],
);
// 파일 뷰어 열기/닫기
const handleFileView = useCallback((file: FileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
}, []);
const handleViewerClose = useCallback(() => {
setIsViewerOpen(false);
setViewerFile(null);
}, []);
// 파일 다운로드
const handleFileDownload = useCallback(async (file: FileInfo) => {
try {
await downloadFile({
fileId: file.objid,
serverFilename: file.savedFileName,
originalName: file.realFileName,
});
toast.success(`${file.realFileName} 다운로드 완료`);
} catch (error) {
console.error("파일 다운로드 오류:", error);
toast.error("파일 다운로드 실패");
}
}, []);
// 파일 삭제
const handleFileDelete = useCallback(
async (file: FileInfo | string) => {
try {
const fileId = typeof file === "string" ? file : file.objid;
const fileName = typeof file === "string" ? "파일" : file.realFileName;
const serverFilename = typeof file === "string" ? "temp_file" : file.savedFileName;
await deleteFile(fileId, serverFilename);
const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId);
setUploadedFiles(updatedFiles);
// localStorage 백업
try {
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
}
// 전역 상태 업데이트
if (typeof window !== "undefined") {
const globalFileState = (window as any).globalFileState || {};
const uniqueKey = getUniqueKey();
globalFileState[uniqueKey] = updatedFiles;
(window as any).globalFileState = globalFileState;
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: id,
uniqueKey: uniqueKey,
recordId: recordId,
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now(),
action: "delete",
},
});
window.dispatchEvent(syncEvent);
}
if (onUpdate) {
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: Date.now(),
});
}
// onChange 콜백
const fileIds = updatedFiles.map((f) => f.objid);
const finalValue = config.multiple ? fileIds : fileIds[0] || "";
const targetColumn = columnName || effectiveColumnName;
console.log("🗑️ [V2Media] 파일 삭제 완료 - 값 전달:", {
columnName: targetColumn,
fileIds,
finalValue,
});
if (onChange) {
onChange(finalValue);
}
// 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식
if (onFormDataChange && targetColumn) {
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
// 복수 파일: 콤마 구분 문자열로 전달
const formValue = config.multiple ? fileIds.join(",") : fileIds[0] || "";
console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", {
columnName: targetColumn,
fileIds,
formValue,
});
// (fieldName: string, value: any) 형식으로 호출
onFormDataChange(targetColumn, formValue);
}
toast.success(`${fileName} 삭제 완료`);
} catch (error) {
console.error("파일 삭제 오류:", error);
toast.error("파일 삭제 실패");
}
},
[
uploadedFiles,
onUpdate,
id,
isRecordMode,
onFormDataChange,
recordTableName,
recordId,
effectiveColumnName,
getUniqueKey,
onChange,
config.multiple,
columnName,
],
);
// 대표 이미지 로드
const loadRepresentativeImage = useCallback(
async (file: FileInfo) => {
try {
const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
file.fileExt.toLowerCase().replace(".", ""),
);
if (!isImage) {
setRepresentativeImageUrl(null);
return;
}
if (!file.objid || file.objid === "0" || file.objid === "") {
setRepresentativeImageUrl(null);
return;
}
const response = await apiClient.get(`/files/download/${file.objid}`, {
params: { serverFilename: file.savedFileName },
responseType: "blob",
});
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
if (representativeImageUrl) {
window.URL.revokeObjectURL(representativeImageUrl);
}
setRepresentativeImageUrl(url);
} catch (error) {
console.error("대표 이미지 로드 실패:", error);
setRepresentativeImageUrl(null);
}
},
[representativeImageUrl],
);
// 대표 이미지 설정
const handleSetRepresentative = useCallback(
async (file: FileInfo) => {
try {
const { setRepresentativeFile } = await import("@/lib/api/file");
await setRepresentativeFile(file.objid);
const updatedFiles = uploadedFiles.map((f) => ({
...f,
isRepresentative: f.objid === file.objid,
}));
setUploadedFiles(updatedFiles);
loadRepresentativeImage(file);
} catch (e) {
console.error("대표 파일 설정 실패:", e);
}
},
[uploadedFiles, loadRepresentativeImage],
);
// uploadedFiles 변경 시 대표 이미지 로드
useEffect(() => {
const representativeFile = uploadedFiles.find((f) => f.isRepresentative) || uploadedFiles[0];
if (representativeFile) {
loadRepresentativeImage(representativeFile);
} else {
setRepresentativeImageUrl(null);
}
return () => {
if (representativeImageUrl) {
window.URL.revokeObjectURL(representativeImageUrl);
}
};
}, [uploadedFiles]);
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!readonly && !disabled) {
setDragOver(true);
}
},
[readonly, disabled],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
if (!readonly && !disabled) {
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
handleFileUpload(files);
}
}
},
[readonly, disabled, handleFileUpload],
);
// 파일 선택
const handleFileSelect = useCallback(() => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
handleFileUpload(files);
}
e.target.value = "";
},
[handleFileUpload],
);
// 파일 설정
const fileConfig: FileUploadConfig = {
accept: config.accept || "*/*",
multiple: config.multiple || false,
maxSize: config.maxSize || 10 * 1024 * 1024,
disabled: disabled,
readonly: readonly,
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div ref={ref} id={id} className="flex w-full flex-col" style={{ width: componentWidth }}>
{/* 라벨 */}
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: getAdaptiveLabelColor(style?.labelColor),
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="shrink-0 text-sm font-medium"
>
{label}
{required && <span className="ml-0.5 text-amber-500">*</span>}
</Label>
)}
{/* 메인 컨테이너 */}
<div className="min-h-0" style={{ height: componentHeight }}>
<div
className="border-border bg-card relative flex h-full w-full flex-col overflow-hidden rounded-lg border"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* 숨겨진 파일 입력 */}
<input
ref={fileInputRef}
type="file"
multiple={config.multiple}
accept={config.accept}
onChange={handleInputChange}
className="hidden"
disabled={disabled || readonly}
/>
{/* 파일이 있는 경우: 대표 이미지/파일 표시 */}
{uploadedFiles.length > 0 ? (
(() => {
const representativeFile = uploadedFiles.find((f) => f.isRepresentative) || uploadedFiles[0];
const isImage =
representativeFile &&
["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
representativeFile.fileExt.toLowerCase().replace(".", ""),
);
return (
<>
{isImage && representativeImageUrl ? (
<div className="bg-muted/10 relative flex h-full w-full items-center justify-center">
<img
src={representativeImageUrl}
alt={representativeFile.realFileName}
className="h-full w-full object-contain"
/>
</div>
) : isImage && !representativeImageUrl ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="border-primary mb-2 h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-muted-foreground text-sm"> ...</p>
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center">
{getFileIcon(representativeFile.fileExt)}
<p className="mt-3 px-4 text-center text-sm font-medium">{representativeFile.realFileName}</p>
<Badge variant="secondary" className="mt-2">
</Badge>
</div>
)}
{/* 우측 하단 자세히보기 버튼 */}
<div className="absolute right-3 bottom-3">
<Button
variant="secondary"
size="sm"
className="h-8 px-3 text-xs shadow-md"
onClick={() => setIsFileManagerOpen(true)}
>
({uploadedFiles.length})
</Button>
</div>
</>
);
})()
) : (
// 파일이 없는 경우: 업로드 안내
<div
className={cn(
"text-muted-foreground flex h-full w-full cursor-pointer flex-col items-center justify-center",
dragOver && "border-primary bg-primary/5",
(disabled || readonly) && "cursor-not-allowed opacity-50",
)}
onClick={() => !disabled && !readonly && handleFileSelect()}
>
<Upload className="mb-3 h-12 w-12" />
<p className="text-sm font-medium"> </p>
<p className="text-muted-foreground mt-1 text-xs">
{formatFileSize(config.maxSize || 10 * 1024 * 1024)}
{config.accept && config.accept !== "*/*" && ` (${config.accept})`}
</p>
<Button
variant="outline"
size="sm"
className="mt-4 h-8 px-3 text-xs"
onClick={(e) => {
e.stopPropagation();
setIsFileManagerOpen(true);
}}
disabled={disabled || readonly}
>
</Button>
</div>
)}
</div>
</div>
{/* 파일 뷰어 모달 */}
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={handleFileDownload}
onDelete={!isDesignMode ? handleFileDelete : undefined}
/>
{/* 파일 관리 모달 */}
<FileManagerModal
isOpen={isFileManagerOpen}
onClose={() => setIsFileManagerOpen(false)}
uploadedFiles={uploadedFiles}
onFileUpload={handleFileUpload}
onFileDownload={handleFileDownload}
onFileDelete={handleFileDelete}
onFileView={handleFileView}
onSetRepresentative={handleSetRepresentative}
config={fileConfig}
isDesignMode={isDesignMode}
/>
</div>
);
});
V2Media.displayName = "V2Media";
export default V2Media;
File diff suppressed because it is too large Load Diff
@@ -34,7 +34,7 @@ import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
import { NumberingRuleCreateDialog } from "@/components/numbering-rule/NumberingRuleCreateDialog";
import type { V2SelectFilter } from "@/types/v2-components";
import type { OptionFilter } from "@/lib/registry/components/input/use-option-loader";
import {
CP_ICONS,
@@ -179,6 +179,39 @@ interface ColumnOption {
columnLabel: string;
}
type TableSelectOption = {
tableName?: string;
table_name?: string;
displayName?: string;
display_name?: string;
tableComment?: string;
table_comment?: string;
tableLabel?: string;
table_label?: string;
description?: string;
};
function normalizeTableSelectOptions(tables: TableSelectOption[] = []) {
return tables
.map((table) => {
const tableName = table.tableName || table.table_name;
if (!tableName) return null;
const label =
table.displayName ||
table.display_name ||
table.tableComment ||
table.table_comment ||
table.tableLabel ||
table.table_label ||
table.description;
return {
tableName,
label: label ? `${label} (${tableName})` : tableName,
};
})
.filter((table): table is { tableName: string; label: string } => table !== null);
}
interface CategoryValueOption {
valueCode: string;
valueLabel: string;
@@ -302,14 +335,18 @@ const TYPE_VOLATILE_FIELDS = [
"dateFormat", "minDate", "maxDate", "showToday", "maxRangeDays",
// multi 의 maxSelect (mode 는 이미 위 라인에 포함)
"maxSelect",
// defaultValue — type 마다 의미 다르므로 잔재 방지 (예: text "저는 김민호" → entity 로 변경 시 부적합)
"defaultValue",
] as const;
function clearVolatileFields(next: Record<string, any>) {
for (const f of TYPE_VOLATILE_FIELDS) delete next[f];
// spread merge ({...currentConfig, ...newConfig}) 에서 키 제거 (delete) 만 하면 currentConfig 의
// 같은 키가 살아남음. = undefined 로 명시해야 spread 가 덮어씀 → 잔재 완전 제거
for (const f of TYPE_VOLATILE_FIELDS) next[f] = undefined;
}
// 새 (kind, type, format) 으로 config 재작성. 사용자 입력 필드 (label/placeholder/helperText/
// defaultValue/required/editable/disabled 등) 는 보존, type 별 잔재 필드는 일괄 reset.
// required/editable/disabled 등) 는 보존, defaultValue 와 type 별 잔재 필드는 일괄 reset.
function applyTriple(
prev: Record<string, any>,
kind: Kind,
@@ -357,7 +394,11 @@ function applyTriple(
if (format === "list" || format === "tags") {
next.fieldType = "select";
next.source = "static";
if (format === "tags") next.tags = true;
if (format === "list") next.mode = isMulti ? "check" : "dropdown";
if (format === "tags") {
next.tags = true;
next.mode = "tag";
}
} else if (format === "code") {
next.fieldType = "category";
next.source = "category";
@@ -418,7 +459,9 @@ interface InvFieldConfigPanelProps {
onChange: (config: Record<string, any>) => void;
tableName?: string;
columnName?: string;
tables?: Array<{ tableName: string; displayName?: string; tableComment?: string }>;
tables?: TableSelectOption[];
/** 전체 테이블 목록 (entity 참조 테이블 dropdown 용). 없으면 tables 로 폴백. */
allTables?: TableSelectOption[];
menuObjid?: number;
screenTableName?: string;
inputType?: string;
@@ -431,6 +474,7 @@ export const InvFieldConfigPanel: React.FC<InvFieldConfigPanelProps> = ({
tableName,
columnName,
tables = [],
allTables,
screenTableName,
inputType: metaInputType,
componentType,
@@ -662,6 +706,9 @@ export const InvFieldConfigPanel: React.FC<InvFieldConfigPanelProps> = ({
const visibleTypes = TYPES_BY_KIND[currentKind] || [];
const visibleFormats = FORMATS_BY_TYPE[fieldType] || [];
const showFormatTrigger = visibleFormats.length > 1;
const tableOptions = normalizeTableSelectOptions(
allTables && allTables.length > 0 ? allTables : tables,
);
return (
<div
@@ -700,9 +747,9 @@ export const InvFieldConfigPanel: React.FC<InvFieldConfigPanelProps> = ({
onChange={(v) => onChange({ ...config, dataRole, sourceTable: v, sourceColumn: "" })}
>
<option value=""> </option>
{tables.map((t) => (
<option key={t.tableName} value={t.tableName}>
{t.displayName || t.tableComment ? `${t.displayName || t.tableComment} (${t.tableName})` : t.tableName}
{tableOptions.map((table) => (
<option key={table.tableName} value={table.tableName}>
{table.label}
</option>
))}
</CPSelect>
@@ -756,6 +803,7 @@ export const InvFieldConfigPanel: React.FC<InvFieldConfigPanelProps> = ({
tableName={tableName}
columnName={columnName}
tables={tables}
allTables={allTables}
loadingColumns={loadingColumns}
entityColumns={entityColumns}
categoryValues={categoryValues}
@@ -771,7 +819,7 @@ export const InvFieldConfigPanel: React.FC<InvFieldConfigPanelProps> = ({
{filterTargetTable && (
<CPSection title="③ 데이터 필터" desc={`${filterTargetTable} 테이블에서 옵션을 불러올 때 적용`}>
<FilterConditionsSection
filters={(config.filters as V2SelectFilter[]) || []}
filters={(config.filters as OptionFilter[]) || []}
columns={filterColumns}
loadingColumns={loadingFilterColumns}
onFiltersChange={(filters) => updateConfig("filters", filters)}
@@ -863,7 +911,9 @@ type FormatBodyProps = {
onChange: (config: Record<string, any>) => void;
tableName?: string;
columnName?: string;
tables: Array<{ tableName: string; displayName?: string; tableComment?: string }>;
tables: TableSelectOption[];
/** 전체 테이블 목록 (entity 참조 테이블 dropdown 용). 없으면 tables 폴백. */
allTables?: TableSelectOption[];
loadingColumns: boolean;
entityColumns: ColumnOption[];
categoryValues: CategoryValueOption[];
@@ -900,7 +950,7 @@ function FormatBody(p: FormatBodyProps) {
if (format === "entity") return (
<EntityOptions
config={config}
tables={p.tables}
tables={(p.allTables && p.allTables.length > 0) ? p.allTables : p.tables}
loadingColumns={p.loadingColumns}
entityColumns={p.entityColumns}
onChange={onChange}
@@ -1528,13 +1578,15 @@ function EntityOptions({
multi = false,
}: {
config: Record<string, any>;
tables: Array<{ tableName: string; displayName?: string; tableComment?: string }>;
tables: TableSelectOption[];
loadingColumns: boolean;
entityColumns: ColumnOption[];
onChange: (config: Record<string, any>) => void;
updateConfig: (k: string, v: any) => void;
multi?: boolean;
}) {
const tableOptions = normalizeTableSelectOptions(tables);
return (
<>
<CPRow label="참조 테이블" required>
@@ -1545,9 +1597,9 @@ function EntityOptions({
}
>
<option value=""> </option>
{tables.map((t) => (
<option key={t.tableName} value={t.tableName}>
{t.displayName || t.tableComment ? `${t.displayName || t.tableComment} (${t.tableName})` : t.tableName}
{tableOptions.map((table) => (
<option key={table.tableName} value={table.tableName}>
{table.label}
</option>
))}
</CPSelect>
@@ -1998,6 +2050,7 @@ function SelectAdvancedOptions({
<option value="combobox"> </option>
{!multi && <option value="radio"> </option>}
{multi && <option value="check"></option>}
{multi && <option value="swap">( )</option>}
{multi && <option value="tag"> </option>}
{!multi && <option value="toggle"></option>}
</CPSelect>
@@ -2081,14 +2134,14 @@ function InputAdvancedOptions({
// 필터 조건 (선택형)
// ───────────────────────────────────────────────────────
const FilterConditionsSection: React.FC<{
filters: V2SelectFilter[];
filters: OptionFilter[];
columns: ColumnOption[];
loadingColumns: boolean;
onFiltersChange: (filters: V2SelectFilter[]) => void;
onFiltersChange: (filters: OptionFilter[]) => void;
}> = ({ filters, columns, loadingColumns, onFiltersChange }) => {
const addFilter = () =>
onFiltersChange([...filters, { column: "", operator: "=", value_type: "static", value: "" }]);
const updateFilter = (index: number, patch: Partial<V2SelectFilter>) => {
const updateFilter = (index: number, patch: Partial<OptionFilter>) => {
const updated = [...filters];
updated[index] = { ...updated[index], ...patch };
if (patch.value_type) {
@@ -2190,7 +2243,7 @@ const FilterConditionsSection: React.FC<{
<div style={{ flex: 1, minWidth: 0 }}>
<CPSelect
value={filter.operator || "="}
onChange={(v) => updateFilter(index, { operator: v as V2SelectFilter["operator"] })}
onChange={(v) => updateFilter(index, { operator: v as OptionFilter["operator"] })}
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>
@@ -2208,7 +2261,7 @@ const FilterConditionsSection: React.FC<{
<div style={{ width: 100, flexShrink: 0 }}>
<CPSelect
value={filter.value_type || "static"}
onChange={(v) => updateFilter(index, { value_type: v as V2SelectFilter["value_type"] })}
onChange={(v) => updateFilter(index, { value_type: v as OptionFilter["value_type"] })}
>
{VALUE_TYPE_OPTIONS.map((vt) => (
<option key={vt.value} value={vt.value}>
@@ -2237,7 +2290,7 @@ const FilterConditionsSection: React.FC<{
{filter.value_type === "user" && (
<CPSelect
value={filter.user_field || ""}
onChange={(v) => updateFilter(index, { user_field: v as V2SelectFilter["user_field"] })}
onChange={(v) => updateFilter(index, { user_field: v as OptionFilter["user_field"] })}
>
<option value=""> </option>
{USER_FIELD_OPTIONS.map((uf) => (
@@ -275,8 +275,7 @@ export const V2AggregationWidgetConfigPanel: React.FC<V2AggregationWidgetConfigP
if (excludeTypes.some((ex) => type.includes(ex))) return false;
const isInput = type.includes("input") || type.includes("select") || type.includes("date") ||
type.includes("checkbox") || type.includes("radio") || type.includes("textarea") ||
type.includes("number") || type === "v2-input" || type === "v2-select" ||
type === "v2-hierarchy";
type.includes("number") || type === "v2-hierarchy";
return isInput || !!comp.columnName;
})
.map((comp) => ({
@@ -1,371 +0,0 @@
"use client";
/**
* V2FileUpload
* UX: 파일 () -> -> /(Switch) -> ()
*/
import React, { useState, useMemo, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Settings,
ChevronDown,
FileText,
Image,
Archive,
File,
FileImage,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { FileUploadConfig } from "@/lib/registry/components/v2-file-upload/types";
import { V2FileUploadDefaultConfig } from "@/lib/registry/components/v2-file-upload/config";
const FILE_TYPE_CARDS = [
{ value: "*/*", label: "모든 파일", icon: File, desc: "제한 없음" },
{ value: "image/*", label: "이미지", icon: Image, desc: "JPG, PNG 등" },
{ value: ".pdf,.doc,.docx,.xls,.xlsx", label: "문서", icon: FileText, desc: "PDF, Word, Excel" },
{ value: "image/*,.pdf", label: "이미지+PDF", icon: FileImage, desc: "이미지와 PDF" },
{ value: ".zip,.rar,.7z", label: "압축 파일", icon: Archive, desc: "ZIP, RAR 등" },
] as const;
const VARIANT_CARDS = [
{ value: "default", label: "기본", desc: "기본 스타일" },
{ value: "outlined", label: "테두리", desc: "테두리 강조" },
{ value: "filled", label: "채움", desc: "배경 채움" },
] as const;
const SIZE_CARDS = [
{ value: "sm", label: "작게" },
{ value: "md", label: "보통" },
{ value: "lg", label: "크게" },
] as const;
interface V2FileUploadConfigPanelProps {
config: FileUploadConfig;
onChange: (config: Partial<FileUploadConfig>) => void;
screenTableName?: string;
}
export const V2FileUploadConfigPanel: React.FC<V2FileUploadConfigPanelProps> = ({
config: propConfig,
onChange,
screenTableName,
}) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const config = useMemo(() => ({
...V2FileUploadDefaultConfig,
...propConfig,
}), [propConfig]);
const maxSizeMB = useMemo(() => {
return (config.maxSize || 10 * 1024 * 1024) / (1024 * 1024);
}, [config.maxSize]);
const updateConfig = useCallback(<K extends keyof FileUploadConfig>(
field: K,
value: FileUploadConfig[K]
) => {
const newConfig = { ...config, [field]: value };
onChange({ [field]: value });
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
}, [config, onChange]);
const handleMaxSizeChange = useCallback((value: string) => {
const mb = parseFloat(value) || 10;
updateConfig("maxSize", mb * 1024 * 1024);
}, [updateConfig]);
return (
<div className="space-y-4">
{/* ─── 1단계: 허용 파일 형식 카드 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="grid grid-cols-2 gap-2">
{FILE_TYPE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = (config.accept || "*/*") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("accept", card.value)}
className={cn(
"flex items-center gap-2 rounded-lg border p-2.5 text-left transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<span className="text-xs font-medium block">{card.label}</span>
<span className="text-[10px] text-muted-foreground block">
{card.desc}
</span>
</div>
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
{/* ─── 2단계: 파일 제한 설정 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<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>
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-xs text-muted-foreground"> (MB)</span>
<Input
type="number"
min={1}
max={100}
value={maxSizeMB}
onChange={(e) => handleMaxSizeChange(e.target.value)}
className="mt-1 h-7 text-xs"
/>
</div>
<div>
<span className="text-xs text-muted-foreground"> </span>
<Input
type="number"
min={1}
max={50}
value={config.maxFiles || 10}
onChange={(e) => updateConfig("maxFiles", parseInt(e.target.value) || 10)}
className="mt-1 h-7 text-xs"
/>
</div>
</div>
</div>
</div>
{/* ─── 3단계: 동작 설정 (Switch) ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="rounded-lg border bg-muted/30 p-4 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.multiple !== false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
</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.allowDelete !== false}
onCheckedChange={(checked) => updateConfig("allowDelete", checked)}
/>
</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.allowDownload !== false}
onCheckedChange={(checked) => updateConfig("allowDownload", checked)}
/>
</div>
</div>
</div>
{/* ─── 4단계: 표시 설정 (Switch) ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> </p>
<div className="rounded-lg border bg-muted/30 p-4 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.showPreview !== false}
onCheckedChange={(checked) => updateConfig("showPreview", checked)}
/>
</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.showFileList !== false}
onCheckedChange={(checked) => updateConfig("showFileList", checked)}
/>
</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.showFileSize !== false}
onCheckedChange={(checked) => updateConfig("showFileSize", checked)}
/>
</div>
</div>
</div>
{/* ─── 5단계: 스타일 카드 선택 ─── */}
<div className="space-y-3">
<p className="text-sm font-medium"></p>
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div>
<span className="text-xs text-muted-foreground"> </span>
<div className="mt-1.5 grid grid-cols-3 gap-2">
{VARIANT_CARDS.map((card) => {
const isSelected = (config.variant || "default") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("variant", card.value as "default" | "outlined" | "filled")}
className={cn(
"flex flex-col items-center rounded-md border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium">{card.label}</span>
<span className="text-[10px] text-muted-foreground">{card.desc}</span>
</button>
);
})}
</div>
</div>
<div>
<span className="text-xs text-muted-foreground"></span>
<div className="mt-1.5 grid grid-cols-3 gap-2">
{SIZE_CARDS.map((card) => {
const isSelected = (config.size || "md") === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("size", card.value as "sm" | "md" | "lg")}
className={cn(
"flex items-center justify-center rounded-md border p-2 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<span className="text-xs font-medium">{card.label}</span>
</button>
);
})}
</div>
</div>
</div>
</div>
{/* ─── 6단계: 고급 설정 (기본 접혀있음) ─── */}
<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">
{/* 도움말 */}
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"></span>
<Input
value={config.helperText || ""}
onChange={(e) => updateConfig("helperText", e.target.value)}
placeholder="안내 문구 입력"
className="h-7 w-[160px] text-xs"
/>
</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.required || false}
onCheckedChange={(checked) => updateConfig("required", checked)}
/>
</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 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.disabled || false}
onCheckedChange={(checked) => updateConfig("disabled", checked)}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2FileUploadConfigPanel.displayName = "V2FileUploadConfigPanel";
export default V2FileUploadConfigPanel;
File diff suppressed because it is too large Load Diff
@@ -1,322 +0,0 @@
"use client";
/**
* V2Media
* UX: 미디어 -> -> -> ()
*/
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
FileText,
Image,
Video,
Music,
Settings,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── 미디어 타입 카드 정의 ───
const MEDIA_TYPE_CARDS = [
{
value: "file",
icon: FileText,
title: "파일",
description: "일반 파일을 업로드해요",
},
{
value: "image",
icon: Image,
title: "이미지",
description: "사진이나 그림을 올려요",
},
{
value: "video",
icon: Video,
title: "비디오",
description: "동영상을 업로드해요",
},
{
value: "audio",
icon: Music,
title: "오디오",
description: "음악이나 녹음을 올려요",
},
] as const;
interface V2MediaConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export const V2MediaConfigPanel: React.FC<V2MediaConfigPanelProps> = ({
config,
onChange,
}) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
const currentMediaType = config.mediaType || config.type || "image";
const isImageType = currentMediaType === "image";
const isPlayerType = currentMediaType === "video" || currentMediaType === "audio";
return (
<div className="space-y-4">
{/* ─── 1단계: 미디어 타입 선택 (카드) ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> ?</p>
<div className="grid grid-cols-2 gap-2">
{MEDIA_TYPE_CARDS.map((card) => {
const Icon = card.icon;
const isSelected = currentMediaType === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("mediaType", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50",
)}
>
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">
{card.title}
</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{card.description}
</span>
</button>
);
})}
</div>
</div>
{/* ─── 2단계: 기본 설정 ─── */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<span className="text-sm font-medium"> </span>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.accept || ""}
onChange={(e) => updateConfig("accept", e.target.value)}
placeholder=".jpg,.png,.pdf"
className="h-8 w-[180px] text-sm"
/>
</div>
</div>
{/* ─── 3단계: 업로드 옵션 (Switch + 설명) ─── */}
<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={(checked) => updateConfig("multiple", checked)}
/>
</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.preview !== false}
onCheckedChange={(checked) => updateConfig("preview", checked)}
/>
</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.dragDrop !== false}
onCheckedChange={(checked) => updateConfig("dragDrop", checked)}
/>
</div>
</div>
{/* ─── 4단계: 타입별 설정 ─── */}
{/* 이미지 타입: 크기 제한 + 자르기 */}
{isImageType && (
<div className="space-y-3">
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Image className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
<div className="flex gap-2">
<div className="flex-1">
<span className="text-xs text-muted-foreground"> (px)</span>
<Input
type="number"
value={config.maxWidth || ""}
onChange={(e) => updateConfig("maxWidth", e.target.value ? Number(e.target.value) : undefined)}
placeholder="자동"
className="mt-1 h-8 text-sm"
/>
</div>
<div className="flex-1">
<span className="text-xs text-muted-foreground"> (px)</span>
<Input
type="number"
value={config.maxHeight || ""}
onChange={(e) => updateConfig("maxHeight", e.target.value ? Number(e.target.value) : undefined)}
placeholder="자동"
className="mt-1 h-8 text-sm"
/>
</div>
</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.crop || false}
onCheckedChange={(checked) => updateConfig("crop", checked)}
/>
</div>
</div>
)}
{/* 비디오/오디오 타입: 플레이어 설정 */}
{isPlayerType && (
<div className="space-y-2">
<div className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-2">
{currentMediaType === "video" ? (
<Video className="h-4 w-4 text-primary" />
) : (
<Music className="h-4 w-4 text-primary" />
)}
<span className="text-sm font-medium"> </span>
</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.autoplay || false}
onCheckedChange={(checked) => updateConfig("autoplay", checked)}
/>
</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.controls !== false}
onCheckedChange={(checked) => updateConfig("controls", checked)}
/>
</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.loop || false}
onCheckedChange={(checked) => updateConfig("loop", checked)}
/>
</div>
</div>
)}
{/* ─── 5단계: 고급 설정 (기본 접혀있음) ─── */}
<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">
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> (MB)</span>
<Input
type="number"
value={config.maxSize || ""}
onChange={(e) => updateConfig("maxSize", e.target.value ? Number(e.target.value) : undefined)}
placeholder="10"
min="1"
className="h-8 w-[180px] text-sm"
/>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground"> </span>
<Input
type="number"
value={config.maxFiles || ""}
onChange={(e) => updateConfig("maxFiles", e.target.value ? Number(e.target.value) : undefined)}
placeholder="제한 없음"
min="1"
className="h-8 w-[180px] text-sm"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2MediaConfigPanel.displayName = "V2MediaConfigPanel";
export default V2MediaConfigPanel;
@@ -1,847 +0,0 @@
"use client";
/**
* V2Select
* UX: 소스 -> -> ()
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
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 { List, Database, FolderTree, Settings, ChevronDown, Plus, Trash2, Loader2, Filter } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import type { V2SelectFilter } from "@/types/v2-components";
interface ColumnOption {
columnName: string;
columnLabel: string;
}
interface CategoryValueOption {
valueCode: string;
valueLabel: string;
}
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;
// ─── 데이터 소스 카드 정의 ───
const SOURCE_CARDS = [
{
value: "static",
icon: List,
title: "직접 입력",
description: "옵션을 직접 추가해요",
},
{
value: "category",
icon: FolderTree,
title: "카테고리",
description: "등록된 선택지를 사용해요",
},
{
value: "entity",
icon: Database,
title: "테이블 참조",
description: "다른 테이블에서 가져와요",
},
] as const;
/**
*
*/
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: "=", value_type: "static", value: "" },
]);
};
const updateFilter = (index: number, patch: Partial<V2SelectFilter>) => {
const updated = [...filters];
updated[index] = { ...updated[index], ...patch };
if (patch.value_type) {
if (patch.value_type === "static") {
updated[index].field_ref = undefined;
updated[index].user_field = undefined;
} else if (patch.value_type === "field") {
updated[index].value = undefined;
updated[index].user_field = undefined;
} else if (patch.value_type === "user") {
updated[index].value = undefined;
updated[index].field_ref = undefined;
}
}
if (patch.operator === "isNull" || patch.operator === "isNotNull") {
updated[index].value = undefined;
updated[index].field_ref = undefined;
updated[index].user_field = undefined;
updated[index].value_type = "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.value_type || "static"}
onValueChange={(v) => updateFilter(index, { value_type: v as V2SelectFilter["value_type"] })}
>
<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.value_type || "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.value_type === "field" && (
<Input
value={filter.field_ref || ""}
onChange={(e) => updateFilter(index, { field_ref: e.target.value })}
placeholder="참조할 필드명 (columnName)"
className="h-7 flex-1 text-[11px]"
/>
)}
{filter.value_type === "user" && (
<Select
value={filter.user_field || ""}
onValueChange={(v) => updateFilter(index, { user_field: v as V2SelectFilter["user_field"] })}
>
<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 V2SelectConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
inputType?: string;
tableName?: string;
columnName?: string;
tables?: Array<{ tableName: string; displayName?: string; tableComment?: string }>;
screenTableName?: string;
}
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
config,
onChange,
inputType,
tableName,
columnName,
tables = [],
screenTableName,
}) => {
const isEntityType = inputType === "entity" || config.source === "entity" || !!config.entityTable;
const isCategoryType = inputType === "category";
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 filterTargetTable = useMemo(() => {
const src = config.source || "static";
if (src === "entity") return config.entityTable;
if (src === "db") return config.table;
if (src === "distinct" || src === "select") return tableName;
return null;
}, [config.source, config.entityTable, config.table, tableName]);
useEffect(() => {
if (!filterTargetTable) {
setFilterColumns([]);
return;
}
const loadFilterColumns = async () => {
setLoadingFilterColumns(true);
try {
const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`);
const data = response.data.data || response.data;
const columns = data.columns || data || [];
setFilterColumns(
columns.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);
}
};
loadFilterColumns();
}, [filterTargetTable]);
// 초기 source가 설정 안 된 경우에만 기본값 설정
useEffect(() => {
if (!config.source && isCategoryType) {
onChange({ ...config, source: "category" });
}
}, []);
const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
if (!catTable || !catColumn) {
setCategoryValues([]);
return;
}
setLoadingCategoryValues(true);
try {
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
const data = response.data;
if (data.success && data.data) {
const flattenTree = (items: any[], depth: number = 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 && item.children.length > 0) {
result.push(...flattenTree(item.children, depth + 1));
}
}
return result;
};
setCategoryValues(flattenTree(data.data));
}
} catch (error) {
console.error("카테고리 값 조회 실패:", error);
setCategoryValues([]);
} finally {
setLoadingCategoryValues(false);
}
}, []);
useEffect(() => {
if (config.source === "category" || config.source === "code") {
const catTable = config.categoryTable || tableName;
const catColumn = config.categoryColumn || columnName;
if (catTable && catColumn) {
loadCategoryValues(catTable, catColumn);
}
}
}, [config.source, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]);
const loadEntityColumns = useCallback(async (tblName: string) => {
if (!tblName) {
setEntityColumns([]);
return;
}
setLoadingColumns(true);
try {
const response = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`);
const data = response.data.data || response.data;
const columns = data.columns || data || [];
const columnOptions: ColumnOption[] = columns.map((col: any) => {
const name = col.columnName || col.column_name || col.name;
const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name;
return {
columnName: name,
columnLabel: label,
};
});
setEntityColumns(columnOptions);
} catch (error) {
console.error("컬럼 목록 조회 실패:", error);
setEntityColumns([]);
} finally {
setLoadingColumns(false);
}
}, []);
useEffect(() => {
if (config.source === "entity" && config.entityTable) {
loadEntityColumns(config.entityTable);
}
}, [config.source, config.entityTable, loadEntityColumns]);
const options = config.options || [];
const addOption = () => {
const newOptions = [...options, { value: "", label: "" }];
updateConfig("options", newOptions);
};
const updateOptionValue = (index: number, value: string) => {
const newOptions = [...options];
newOptions[index] = { ...newOptions[index], value, label: value };
updateConfig("options", newOptions);
};
const removeOption = (index: number) => {
const newOptions = options.filter((_: any, i: number) => i !== index);
updateConfig("options", newOptions);
};
const effectiveSource = config.source === "code"
? "category"
: config.source || (isCategoryType ? "category" : "static");
const visibleCards = SOURCE_CARDS;
const gridCols = "grid-cols-3";
return (
<div className="space-y-4">
{/* ─── 1단계: 데이터 소스 선택 ─── */}
<div className="space-y-2">
<p className="text-sm font-medium"> ?</p>
<div className={cn("grid gap-2", gridCols)}>
{visibleCards.map((card) => {
const Icon = card.icon;
const isSelected = effectiveSource === card.value;
return (
<button
key={card.value}
type="button"
onClick={() => updateConfig("source", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">{card.title}</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">{card.description}</span>
</button>
);
})}
</div>
</div>
{/* ─── 2단계: 소스별 설정 ─── */}
{/* 직접 입력 (static) */}
{effectiveSource === "static" && (
<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={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
>
<SelectTrigger className="h-8 w-[160px] text-sm">
<SelectValue placeholder="선택 안함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{options.map((option: any, index: number) => (
<SelectItem key={`default-${index}`} value={option.value || `_idx_${index}`}>
{option.label || option.value || `옵션 ${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
)}
{/* 테이블 참조 (entity) */}
{effectiveSource === "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>
)}
{/* 카테고리 (category) - source="code" 하위 호환 포함 */}
{effectiveSource === "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>
<p className="mt-1 text-[11px] text-muted-foreground">
</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={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
>
<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>
)}
{/* 데이터 필터 (static 제외, filterTargetTable 있을 때만) */}
{effectiveSource !== "static" && 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">
{/* 선택 모드 */}
<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="tagbox"></SelectItem>
<SelectItem value="toggle"></SelectItem>
<SelectItem value="swap"></SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-[11px] text-muted-foreground"> </p>
</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={(checked) => updateConfig("multiple", checked)}
/>
</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={(checked) => updateConfig("searchable", checked)}
/>
</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={(checked) => updateConfig("allowClear", checked)}
/>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2SelectConfigPanel.displayName = "V2SelectConfigPanel";
export default V2SelectConfigPanel;
@@ -2,12 +2,11 @@
* V2
*/
export { V2InputConfigPanel } from "./V2InputConfigPanel";
export { V2SelectConfigPanel } from "./V2SelectConfigPanel";
// 옛 입력/선택 ConfigPanel 은 Phase D.3 에서 폐기됨 — InvFieldConfigPanel 사용.
export { V2ListConfigPanel } from "./V2ListConfigPanel";
export { V2LayoutConfigPanel } from "./V2LayoutConfigPanel";
export { V2GroupConfigPanel } from "./V2GroupConfigPanel";
export { V2MediaConfigPanel } from "./V2MediaConfigPanel";
// V2MediaConfigPanel — Phase D.5 폐기. canonical InvFieldConfigPanel 의 attach/file 분기로 흡수.
export { V2BizConfigPanel } from "./V2BizConfigPanel";
export { V2HierarchyConfigPanel } from "./V2HierarchyConfigPanel";
+5 -22
View File
@@ -4,21 +4,18 @@
* V2
*/
// Phase 1 컴포넌트
export { V2Input } from "./V2Input";
export { V2Select } from "./V2Select";
// 옛 입력/선택 컴포넌트는 Phase D.3 (2026-05-12) 에서 폐기됨 — canonical `input` 으로 흡수.
// Phase 2 컴포넌트
export { V2List } from "./V2List";
export { V2Layout } from "./V2Layout";
export { V2Group } from "./V2Group";
// Phase 3 컴포넌트
export { V2Media } from "./V2Media";
// Phase 3 컴포넌트 (V2Media 는 Phase D.5 폐기 — canonical input 의 file 분기로 흡수)
export { V2Biz } from "./V2Biz";
export { V2Hierarchy } from "./V2Hierarchy";
// V2Text는 V2Inputtextarea 모드로 대체 가능
// V2Text canonical `input` (textarea 모드) 으로 흡수됨
// 렌더러
export { V2ComponentRenderer } from "./V2ComponentRenderer";
@@ -66,18 +63,7 @@ export type {
CascadingConfig,
MutualExclusionConfig,
// V2Input 타입
V2InputType,
V2InputFormat,
V2InputConfig,
V2InputProps,
// V2Select 타입
V2SelectMode,
V2SelectSource,
SelectOption,
V2SelectConfig,
V2SelectProps,
// (옛 입력/선택 타입은 Phase D.3 에서 제거됨 — canonical InputConfig 와 OptionFilter 로 이전)
// V2List 타입
V2ListViewMode,
@@ -96,10 +82,7 @@ export type {
V2GroupConfig,
V2GroupProps,
// V2Media 타입
V2MediaType,
V2MediaConfig,
V2MediaProps,
// V2Media 타입은 Phase D.5 에서 v2-components 에서 제거됨 (canonical input 으로 흡수)
// V2Biz 타입
V2BizType,
+6 -58
View File
@@ -1,33 +1,28 @@
"use client";
/**
* V2
* V2-era
*
* 9 V2 ComponentRegistry에 .
* canonical INV .
* `input` canonical (Phase D.2, 2026-05-12 V2 / ).
*/
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component";
import { WebType } from "@/types/screen";
// 실제 컴포넌트 import
import { V2Input } from "./V2Input";
import { V2Select } from "./V2Select";
import { V2List } from "./V2List";
import { V2Layout } from "./V2Layout";
import { V2Group } from "./V2Group";
import { V2Media } from "./V2Media";
// V2Media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수.
import { V2Biz } from "./V2Biz";
import { V2Hierarchy } from "./V2Hierarchy";
import { V2Repeater } from "./V2Repeater";
// 설정 패널 import
import { V2InputConfigPanel } from "./config-panels/V2InputConfigPanel";
import { V2SelectConfigPanel } from "./config-panels/V2SelectConfigPanel";
import { V2ListConfigPanel } from "./config-panels/V2ListConfigPanel";
import { V2LayoutConfigPanel } from "./config-panels/V2LayoutConfigPanel";
import { V2GroupConfigPanel } from "./config-panels/V2GroupConfigPanel";
import { V2MediaConfigPanel } from "./config-panels/V2MediaConfigPanel";
// V2MediaConfigPanel — Phase D.5 폐기.
import { V2BizConfigPanel } from "./config-panels/V2BizConfigPanel";
import { V2HierarchyConfigPanel } from "./config-panels/V2HierarchyConfigPanel";
import { InvRepeaterConfigPanel } from "./config-panels/InvRepeaterConfigPanel";
@@ -35,38 +30,6 @@ import { InvDataConfigPanel } from "./config-panels/InvDataConfigPanel";
// V2 컴포넌트 정의
const v2ComponentDefinitions: ComponentDefinition[] = [
{
id: "v2-input",
name: "통합 입력",
description: "텍스트, 숫자, 비밀번호, 슬라이더, 컬러 등 다양한 입력 타입을 지원하는 통합 컴포넌트",
category: ComponentCategory.V2,
web_type: "text" as WebType,
component: V2Input as any,
tags: ["input", "text", "number", "password", "slider", "color", "v2"],
default_size: { width: 200, height: 40 },
config_panel: V2InputConfigPanel as any,
default_config: {
inputType: "text",
format: "none",
placeholder: "",
},
},
{
id: "v2-select",
name: "통합 선택",
description: "드롭다운, 라디오, 체크박스, 태그, 토글 등 다양한 선택 방식을 지원하는 통합 컴포넌트",
category: ComponentCategory.V2,
web_type: "select" as WebType,
component: V2Select as any,
tags: ["select", "dropdown", "radio", "checkbox", "toggle", "v2"],
default_size: { width: 200, height: 40 },
config_panel: V2SelectConfigPanel as any,
default_config: {
mode: "dropdown",
source: "static",
options: [],
},
},
{
id: "v2-list",
name: "통합 목록",
@@ -119,22 +82,7 @@ const v2ComponentDefinitions: ComponentDefinition[] = [
defaultOpen: true,
},
},
{
id: "v2-media",
name: "통합 미디어",
description: "이미지, 비디오, 오디오, 파일 업로드 등을 지원하는 통합 컴포넌트",
category: ComponentCategory.V2,
web_type: "file" as WebType,
component: V2Media as any,
tags: ["media", "image", "video", "audio", "file", "upload", "v2"],
default_size: { width: 300, height: 200 },
config_panel: V2MediaConfigPanel as any,
default_config: {
mediaType: "image",
multiple: false,
preview: true,
},
},
// v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수. 등록 제거.
{
id: "v2-biz",
name: "통합 비즈니스",
@@ -222,7 +222,7 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
toast.error(`${firstEmptyLabel} 항목을 입력해주세요`);
}
// V2Select는 input/change 이벤트가 없으므로 DOM 변경 감지로 에러 동기화
// dropdown/combobox 류 picker 는 input/change 이벤트가 없으므로 DOM 변경 감지로 에러 동기화
const observer = new MutationObserver(syncErrors);
observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-placeholder"] });
@@ -336,7 +336,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}, [screenTableName]);
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
// 레거시 저장 데이터의 url 마지막 세그먼트를 컴포넌트 타입으로 사용
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
if (!url) return undefined;
// url의 마지막 세그먼트를 컴포넌트 타입으로 사용
@@ -349,8 +349,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지)
// ★ 2026-04-11: INVYONE 통합 컴포넌트(Phase A~) 는 v2- 매핑에서 제외.
// 예: 'input' 을 'v2-input' 으로 리다이렉트하면 새 통합 Input 대신 기존
// v2-input 이 렌더되어 설정 변경이 안 반영됨.
// ★ 2026-05-12: V2 입력/선택은 완전 폐기 — 매핑/alias/fallback 모두 제거.
const INVYONE_UNIFIED_IDS = new Set([
"divider",
"title",
@@ -373,8 +372,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
"v2-button-primary": "button", "button-primary": "button",
// search
"v2-table-search-widget": "search", "table-search-widget": "search",
// input
"v2-input": "input", "v2-select": "input",
// input (V2 입력/선택은 Phase D.2 에서 완전 폐기 — alias 제거)
"text-input": "input", "number-input": "input", "date-input": "input",
"select-basic": "input", "checkbox-basic": "input", "textarea-basic": "input",
"slider-basic": "input", "radio-basic": "input", "toggle-switch": "input",
@@ -415,9 +413,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const mappedComponentType = mapToV2ComponentType(rawComponentType);
// ★ canonical 라우팅: fieldType / dbInputType → v2-input/v2-select 강제 swap 제거됨.
// InvField (kind/type/format) 모델이 진실의 원천. mappedComponentType 그대로 사용.
// (이전 분기는 brumb 변경 시 InputComponent → V2Input swap 의 원인이었음)
// ★ canonical 라우팅: InvField (kind/type/format) 모델이 진실의 원천.
// fieldType / dbInputType 기반 강제 swap 분기는 Phase 3 / D.2 에서 제거됨.
const componentType = mappedComponentType;
// 🆕 조건부 렌더링 체크 (conditionalConfig)
@@ -478,7 +475,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}
// 🆕 모든 v2- 컴포넌트는 ComponentRegistry에서 통합 처리
// (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리)
// (v2-repeat-container 등. V2 입력/선택은 Phase D.2 에서 폐기됨)
// 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인)
// DB input_type이 "text" 등 비-카테고리로 변경된 경우 이 분기를 건너뜀
@@ -502,117 +499,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// webType도 DB 값으로 대체 (레이아웃에 webType: "category" 하드코딩되어 있을 수 있음)
const effectiveWebType = dbFieldInputType || webType;
const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode;
const isMultipleSelect = (component as any).componentConfig?.multiple;
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
const isNonDropdownMode = componentMode && nonDropdownModes.includes(componentMode);
const shouldUseV2Select =
componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect;
// DB input_type이 비-카테고리(text 등)로 확인된 경우, 레이아웃에 category가 남아있어도 카테고리 분기 강제 스킵
// dbFieldInputType이 있으면(캐시 로드됨) 그 값으로 판단, 없으면 기존 로직 유지
const isDbConfirmedNonCategory = dbFieldInputType && !["category", "entity", "select"].includes(dbFieldInputType);
if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName && shouldUseV2Select) {
// V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드)
try {
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
const fieldName = columnName || component.id;
// 수평 라벨 감지
const catLabelDisplay = component.style?.label_display ?? (component as any).labelDisplay;
const catLabelPosition = component.style?.label_position;
const catLabelText =
catLabelDisplay === true || catLabelDisplay === "true"
? component.style?.label_text || (component as any).label || component.component_config?.label
: undefined;
const catNeedsExternalHorizLabel = !!(
catLabelText &&
(catLabelPosition === "left" || catLabelPosition === "right")
);
const selectComponent = {
...component,
component_config: {
...component.component_config,
mode: componentMode || "dropdown",
source: "category",
categoryTable: tableName,
categoryColumn: columnName,
},
tableName,
columnName,
inputType: "category",
web_type: "category",
};
const catStyle = catNeedsExternalHorizLabel
? {
...(component as any).style,
label_display: false,
label_position: "top" as const,
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,
border: undefined,
borderRadius: undefined,
}
: (component as any).style;
const catSize = catNeedsExternalHorizLabel
? { ...(component as any).size, width: undefined }
: (component as any).size;
const rendererProps = {
component: selectComponent,
formData: props.formData,
onFormDataChange: props.onFormDataChange,
isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive ?? !props.isDesignMode,
tableName,
style: catStyle,
size: catSize,
};
const rendererInstance = new V2SelectRenderer(rendererProps);
const renderedCatSelect = rendererInstance.render();
if (catNeedsExternalHorizLabel) {
const labelGap = component.style?.label_gap || "8px";
const labelFontSize = component.style?.label_font_size || "14px";
const labelColor = getAdaptiveLabelColor(component.style?.label_color);
const labelFontWeight = component.style?.label_font_weight || "500";
const isRequired =
component.required || (component as any).required || isColumnRequiredByMeta(tableName, columnName);
const isLeft = catLabelPosition === "left";
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(isLeft ? { right: "100%", marginRight: labelGap } : { left: "100%", marginLeft: labelGap }),
fontSize: labelFontSize,
color: labelColor,
fontWeight: labelFontWeight,
whiteSpace: "nowrap",
}}
className="text-sm font-medium"
>
{catLabelText}
{isRequired && <span className="ml-0.5 text-amber-500">*</span>}
</label>
<div style={{ width: "100%", height: "100%" }}>{renderedCatSelect}</div>
</div>
);
}
return renderedCatSelect;
} catch (error) {
console.error("❌ V2SelectRenderer 로드 실패:", error);
}
} else if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName) {
if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName) {
try {
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const fieldName = columnName || component.id;
@@ -821,14 +712,13 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
};
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블)
// 🆕 v2-input도 포함 (채번 규칙 조회 시 tableName 필요)
// (V2 입력 폐기로 채번 규칙 조회용 분기 제거됨 — input canonical 은 자체 props 로 tableName 받음)
const useConfigTableName =
componentType === "entity-search-input" ||
componentType === "autocomplete-search-input" ||
componentType === "modal-repeater-table" ||
componentType === "v2-input";
componentType === "modal-repeater-table";
// 🆕 v2-input 등의 라벨 표시 로직 (InteractiveScreenViewerDynamic과 동일한 부정형 체크)
// 🆕 라벨 표시 로직 (InteractiveScreenViewerDynamic 과 동일한 부정형 체크)
const labelDisplay = component.style?.label_display ?? (component as any).labelDisplay;
const effectiveLabel =
labelDisplay !== false && labelDisplay !== "false"
@@ -7,16 +7,24 @@ import { DynamicComponentProps } from "./types";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
type DynamicWebTypeRendererProps = Omit<DynamicComponentProps, "web_type" | "props"> & {
web_type?: string;
webType?: string;
props?: Record<string, any>;
};
/**
*
* .
*/
export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
webType,
export const DynamicWebTypeRenderer: React.FC<DynamicWebTypeRendererProps> = ({
web_type,
webType: webTypeProp,
props = {},
config = {},
onEvent,
}) => {
const webType = webTypeProp || web_type || "";
// 모든 hooks를 먼저 호출 (조건부 return 이전에)
const { webTypes } = useWebTypes({ active: "Y" });
@@ -54,10 +62,10 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
const mergedConfig = useMemo(() => {
if (!webTypeDefinition) return config;
return {
...webTypeDefinition.defaultConfig,
...webTypeDefinition.default_config,
...config,
};
}, [webTypeDefinition?.defaultConfig, config]);
}, [webTypeDefinition?.default_config, config]);
// 최종 props 구성 (조건부로 사용되지만 항상 계산)
const finalProps = useMemo(() => {
@@ -69,26 +77,36 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
};
}, [props, mergedConfig, webType, onEvent]);
// 0순위: 파일 컴포넌트 강제 처리 (최우선)
if (webType === "file" || props.component?.type === "file") {
// file / image / img / picture / photo — canonical `input` 으로 라우팅 (Phase D.4).
// FileUploadComponent / ImageWidget 의 직접 import 는 끊고, InputComponent 가 자체
// FilePicker 로 처리. 옛 컴포넌트 파일 자체 삭제는 사용처 0건 확인 후 별도 phase.
if (
webType === "file" ||
webType === "image" ||
webType === "img" ||
webType === "picture" ||
webType === "photo" ||
props.component?.type === "file"
) {
try {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
// console.log(`🎯 최우선: 파일 컴포넌트 → FileUploadComponent 사용`);
return <FileUploadComponent {...props} {...finalProps} />;
const { InputComponent } = require("@/lib/registry/components/input/InputComponent");
const isImage = webType === "image" || webType === "img" || webType === "picture" || webType === "photo";
const fileConfig = {
type: "file",
format: isImage ? "image" : "file",
accept: isImage ? "image/*" : "*/*",
multiple: !isImage,
showPreview: isImage,
};
return (
<InputComponent
{...props}
{...finalProps}
config={{ ...fileConfig, ...mergedConfig }}
/>
);
} catch (error) {
console.error("FileUploadComponent 로드 실패:", error);
}
}
// 이미지 컴포넌트 강제 처리
if (webType === "image" || webType === "img" || webType === "picture" || webType === "photo") {
try {
// console.log(`🎯 이미지 컴포넌트 감지! webType: ${webType}`, { props, finalProps });
const { ImageWidget } = require("@/components/screen/widgets/types/ImageWidget");
// console.log(`✅ ImageWidget 로드 성공`);
return <ImageWidget {...props} {...finalProps} />;
} catch (error) {
console.error("❌ ImageWidget 로드 실패:", error);
console.error("canonical input (file) 로드 실패:", error);
}
}
@@ -98,12 +116,8 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
// console.log("DB 웹타입 정보:", dbWebType);
// FileWidget의 경우 FileUploadComponent 직접 사용
if (dbWebType.component_name === "FileWidget" || webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
// console.log("✅ FileWidget → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />;
}
// FileWidget — 위 file/image 통합 분기에서 이미 처리되므로 여기서는 진입 불가.
// 옛 file-upload/FileUploadComponent 직접 import 는 Phase D.4 에서 제거됨.
// 다른 컴포넌트들은 기존 로직 유지
// const ComponentByName = getWidgetComponentByName(dbWebType.component_name);
@@ -122,12 +136,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
if (webTypeDefinition) {
// console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`);
// 파일 웹타입의 경우 FileUploadComponent 직접 사용
if (webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
// console.log("✅ 파일 웹타입 → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />;
}
// 파일 웹타입 — 위 통합 분기에서 이미 canonical input 으로 처리됨. fall-through.
// 웹타입이 비활성화된 경우
if (!webTypeDefinition.isActive) {
@@ -156,12 +165,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
try {
console.warn(`웹타입 "${webType}" → 자동 매핑 폴백 사용`);
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백)
if (webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
// console.log("✅ 폴백: 파일 웹타입 → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />;
}
// 파일 웹타입 — 위 통합 분기에서 처리됨. fallback 도 canonical input 으로 일원화.
// 텍스트 입력 웹타입들
if (["text", "email", "password", "tel"].includes(webType)) {
File diff suppressed because it is too large Load Diff
@@ -1,72 +0,0 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { FileUploadConfig } from "./types";
export interface FileUploadConfigPanelProps {
config: FileUploadConfig;
onChange: (config: Partial<FileUploadConfig>) => void;
}
/**
* FileUpload
* UI
*/
export const FileUploadConfigPanel: React.FC<FileUploadConfigPanelProps> = ({
config,
onChange,
}) => {
const handleChange = (key: keyof FileUploadConfig, value: any) => {
onChange({ [key]: value });
};
return (
<div className="space-y-4">
<div className="text-sm font-medium">
file-upload
</div>
{/* file 관련 설정 */}
<div className="space-y-2">
<Label htmlFor="placeholder"></Label>
<Input
id="placeholder"
value={config.placeholder || ""}
onChange={(e) => handleChange("placeholder", e.target.value)}
/>
</div>
{/* 공통 설정 */}
<div className="space-y-2">
<Label htmlFor="disabled"></Label>
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="required"> </Label>
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="readonly"> </Label>
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
</div>
</div>
);
};
@@ -1,56 +0,0 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { FileUploadDefinition } from "./index";
import { FileUploadComponent } from "./FileUploadComponent";
/**
* FileUpload
*
*/
export class FileUploadRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = FileUploadDefinition;
render(): React.ReactElement {
return <FileUploadComponent {...(this.props as any)} renderer={this} />;
}
/**
*
*/
// file 타입 특화 속성 처리
protected getFileUploadProps() {
const baseProps = this.getWebTypeProps();
// file 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 file 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
FileUploadRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
FileUploadRenderer.enableHotReload();
}
@@ -1,91 +0,0 @@
# FileUpload 컴포넌트
file-upload 컴포넌트입니다
## 개요
- **ID**: `file-upload`
- **카테고리**: input
- **웹타입**: file
- **작성자**: 개발팀
- **버전**: 1.0.0
## 특징
- ✅ 자동 등록 시스템
- ✅ 타입 안전성
- ✅ Hot Reload 지원
- ✅ 설정 패널 제공
- ✅ 반응형 디자인
## 사용법
### 기본 사용법
```tsx
import { FileUploadComponent } from "@/lib/registry/components/file-upload";
<FileUploadComponent
component={{
id: "my-file-upload",
type: "widget",
web_type: "file",
position: { x: 100, y: 100, z: 1 },
size: { width: 250, height: 36 },
config: {
// 설정값들
}
}}
isDesignMode={false}
/>
```
### 설정 옵션
| 속성 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| placeholder | string | "" | 플레이스홀더 텍스트 |
| disabled | boolean | false | 비활성화 여부 |
| required | boolean | false | 필수 입력 여부 |
| readonly | boolean | false | 읽기 전용 여부 |
## 이벤트
- `onChange`: 값 변경 시
- `onFocus`: 포커스 시
- `onBlur`: 포커스 해제 시
- `onClick`: 클릭 시
## 스타일링
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
- `variant`: "default" | "outlined" | "filled"
- `size`: "sm" | "md" | "lg"
## 예시
```tsx
// 기본 예시
<FileUploadComponent
component={{
id: "sample-file-upload",
config: {
placeholder: "입력하세요",
required: true,
variant: "outlined"
}
}}
/>
```
## 개발자 정보
- **생성일**: 2025-09-11
- **CLI 명령어**: `node scripts/create-component.js file-upload --category=input --webType=file`
- **경로**: `lib/registry/components/file-upload/`
## 관련 문서
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
- [개발자 문서](https://docs.example.com/components/file-upload)
@@ -1,40 +0,0 @@
"use client";
import { FileUploadConfig } from "./types";
/**
* FileUpload
*/
export const FileUploadDefaultConfig: FileUploadConfig = {
placeholder: "입력하세요",
// 공통 기본값
disabled: false,
required: false,
readonly: false,
variant: "default",
size: "md",
};
/**
* FileUpload
*
*/
export const FileUploadConfigSchema = {
placeholder: { type: "string", default: "" },
// 공통 스키마
disabled: { type: "boolean", default: false },
required: { type: "boolean", default: false },
readonly: { type: "boolean", default: false },
variant: {
type: "enum",
values: ["default", "outlined", "filled"],
default: "default"
},
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md"
},
};
@@ -1,41 +1,19 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { FileUploadComponent } from "./FileUploadComponent";
import { FileUploadConfigPanel } from "./FileUploadConfigPanel";
import { FileUploadConfig } from "./types";
/**
* FileUpload
* file-upload
* file-upload Phase D.6 (2026-05-12)
*
* FileUploadComponent / FileUploadRenderer / FileUploadConfigPanel
* canonical input + FilePicker .
* / .
*
* :
* GlobalFileViewer.tsx FileViewerModal
* FileAttachmentDetailModal.tsx FileViewerModal + types.FileInfo
* FileComponentConfigPanel.tsx types.FileInfo / FileUploadResponse
* FileManagerModal.tsx FileViewerModal + types
*/
export const FileUploadDefinition = createComponentDefinition({
id: "file-upload",
name: "파일 업로드 (레거시)",
name_eng: "FileUpload Component (Legacy)",
description: "파일 업로드를 위한 파일 선택 컴포넌트 (레거시)",
category: ComponentCategory.INPUT,
web_type: "file",
component: FileUploadComponent,
default_config: {
placeholder: "입력하세요",
},
default_size: { width: 350, height: 240 },
config_panel: FileUploadConfigPanel,
icon: "Edit",
tags: [],
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/file-upload",
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
});
// 타입 내보내기
export type { FileUploadConfig } from "./types";
// 컴포넌트 내보내기
export { FileUploadComponent } from "./FileUploadComponent";
export { FileUploadRenderer } from "./FileUploadRenderer";
export { FileViewerModal } from "./FileViewerModal";
export { FileManagerModal } from "./FileManagerModal";
export type { FileInfo, FileUploadConfig, FileUploadResponse } from "./types";
@@ -1,199 +0,0 @@
"use client";
import React from "react";
import { ComponentRendererProps } from "@/types/component";
import { ImageDisplayConfig } from "./types";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
export interface ImageDisplayComponentProps extends ComponentRendererProps {
config?: ImageDisplayConfig;
}
/**
* ImageDisplay
* image-display
*/
export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
...props
}) => {
const componentConfig = {
...config,
...component.config,
} as ImageDisplayConfig;
const objectFit = componentConfig.objectFit || "contain";
const altText = componentConfig.altText || "이미지";
const borderRadius = componentConfig.borderRadius ?? 8;
const showBorder = componentConfig.showBorder ?? true;
const backgroundColor = componentConfig.backgroundColor || "#f9fafb";
const placeholder = componentConfig.placeholder || "이미지 없음";
const imageSrc = component.value || componentConfig.imageUrl || "";
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
...component.style,
...style,
};
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// DOM에 전달하면 안 되는 React-specific props 필터링
const {
selectedScreen,
onZoneComponentDrop,
onZoneClick,
componentConfig: _componentConfig,
component: _component,
isSelected: _isSelected,
onClick: _onClick,
onDragStart: _onDragStart,
onDragEnd: _onDragEnd,
size: _size,
position: _position,
style: _style,
screenId: _screenId,
tableName: _tableName,
onRefresh: _onRefresh,
onClose: _onClose,
...domProps
} = props;
return (
<div style={componentStyle} className={className} {...filterDOMProps(domProps)}>
{/* 라벨 렌더링 */}
{component.label && (component.style?.labelDisplay ?? true) && (
<label
style={{
position: "absolute",
top: "-25px",
left: "0px",
fontSize: component.style?.labelFontSize || "14px",
color: getAdaptiveLabelColor(component.style?.labelColor),
fontWeight: "500",
}}
>
{component.label}
{(component.required || componentConfig.required) && (
<span style={{ color: "#ef4444" }}>*</span>
)}
</label>
)}
<div
style={{
width: "100%",
height: "100%",
border: showBorder ? "1px solid #d1d5db" : "none",
borderRadius: `${borderRadius}px`,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor,
transition: "all 0.2s ease-in-out",
boxShadow: showBorder ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : "none",
opacity: componentConfig.disabled ? 0.5 : 1,
cursor: componentConfig.disabled ? "not-allowed" : "default",
}}
onMouseEnter={(e) => {
if (!componentConfig.disabled) {
if (showBorder) {
e.currentTarget.style.borderColor = "#f97316";
}
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
}
}}
onMouseLeave={(e) => {
if (showBorder) {
e.currentTarget.style.borderColor = "#d1d5db";
}
e.currentTarget.style.boxShadow = showBorder
? "0 1px 2px 0 rgba(0, 0, 0, 0.05)"
: "none";
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{imageSrc ? (
<img
src={imageSrc}
alt={altText}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit,
}}
onError={(e) => {
const imgEl = e.target as HTMLImageElement;
imgEl.style.display = "none";
if (imgEl.parentElement) {
imgEl.parentElement.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; color: #6b7280; font-size: 14px;">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
<div> </div>
</div>
`;
}
}}
/>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "8px",
color: "#6b7280",
fontSize: "14px",
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<div>{placeholder}</div>
</div>
)}
</div>
</div>
);
};
/**
* ImageDisplay
*/
export const ImageDisplayWrapper: React.FC<ImageDisplayComponentProps> = (props) => {
return <ImageDisplayComponent {...props} />;
};
@@ -1,175 +0,0 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ImageDisplayConfig } from "./types";
export interface ImageDisplayConfigPanelProps {
config: ImageDisplayConfig;
onChange?: (config: Partial<ImageDisplayConfig>) => void;
onConfigChange?: (config: Partial<ImageDisplayConfig>) => void;
}
/**
* ImageDisplay
*/
export const ImageDisplayConfigPanel: React.FC<ImageDisplayConfigPanelProps> = ({
config,
onChange,
onConfigChange,
}) => {
const handleChange = (key: keyof ImageDisplayConfig, value: any) => {
const update = { ...config, [key]: value };
onChange?.(update);
onConfigChange?.(update);
};
return (
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
{/* 이미지 URL */}
<div className="space-y-2">
<Label htmlFor="imageUrl" className="text-xs">
URL
</Label>
<Input
id="imageUrl"
value={config.imageUrl || ""}
onChange={(e) => handleChange("imageUrl", e.target.value)}
placeholder="https://..."
className="h-8 text-xs"
/>
<p className="text-muted-foreground text-[10px]">
</p>
</div>
{/* 대체 텍스트 */}
<div className="space-y-2">
<Label htmlFor="altText" className="text-xs">
(alt)
</Label>
<Input
id="altText"
value={config.altText || ""}
onChange={(e) => handleChange("altText", e.target.value)}
placeholder="이미지 설명"
className="h-8 text-xs"
/>
</div>
{/* 이미지 맞춤 */}
<div className="space-y-2">
<Label htmlFor="objectFit" className="text-xs">
(Object Fit)
</Label>
<Select
value={config.objectFit || "contain"}
onValueChange={(value) => handleChange("objectFit", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contain">Contain ( , )</SelectItem>
<SelectItem value="cover">Cover ( , )</SelectItem>
<SelectItem value="fill">Fill ( )</SelectItem>
<SelectItem value="none">None ( )</SelectItem>
<SelectItem value="scale-down">Scale Down ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 둥글기 */}
<div className="space-y-2">
<Label htmlFor="borderRadius" className="text-xs">
(px)
</Label>
<Input
id="borderRadius"
type="number"
min="0"
max="50"
value={config.borderRadius ?? 8}
onChange={(e) => handleChange("borderRadius", parseInt(e.target.value) || 0)}
className="h-8 text-xs"
/>
</div>
{/* 배경 색상 */}
<div className="space-y-2">
<Label htmlFor="backgroundColor" className="text-xs">
</Label>
<div className="flex items-center gap-2">
<input
type="color"
value={config.backgroundColor || "#f9fafb"}
onChange={(e) => handleChange("backgroundColor", e.target.value)}
className="h-8 w-8 cursor-pointer rounded border"
/>
<Input
id="backgroundColor"
value={config.backgroundColor || "#f9fafb"}
onChange={(e) => handleChange("backgroundColor", e.target.value)}
className="h-8 flex-1 text-xs"
/>
</div>
</div>
{/* 플레이스홀더 */}
<div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs">
</Label>
<Input
id="placeholder"
value={config.placeholder || ""}
onChange={(e) => handleChange("placeholder", e.target.value)}
placeholder="이미지 없음"
className="h-8 text-xs"
/>
</div>
{/* 테두리 표시 */}
<div className="flex items-center gap-2">
<Checkbox
id="showBorder"
checked={config.showBorder ?? true}
onCheckedChange={(checked) => handleChange("showBorder", checked)}
/>
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
</Label>
</div>
{/* 읽기 전용 */}
<div className="flex items-center gap-2">
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
<Label htmlFor="readonly" className="text-xs cursor-pointer">
</Label>
</div>
{/* 필수 입력 */}
<div className="flex items-center gap-2">
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked)}
/>
<Label htmlFor="required" className="text-xs cursor-pointer">
</Label>
</div>
</div>
);
};
@@ -1,56 +0,0 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ImageDisplayDefinition } from "./index";
import { ImageDisplayComponent } from "./ImageDisplayComponent";
/**
* ImageDisplay
*
*/
export class ImageDisplayRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = ImageDisplayDefinition;
render(): React.ReactElement {
return <ImageDisplayComponent {...this.props} renderer={this} />;
}
/**
*
*/
// file 타입 특화 속성 처리
protected getImageDisplayProps() {
const baseProps = this.getWebTypeProps();
// file 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 file 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
ImageDisplayRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
ImageDisplayRenderer.enableHotReload();
}
@@ -1,91 +0,0 @@
# ImageDisplay 컴포넌트
image-display 컴포넌트입니다
## 개요
- **ID**: `image-display`
- **카테고리**: display
- **웹타입**: file
- **작성자**: Developer
- **버전**: 1.0.0
## 특징
- ✅ 자동 등록 시스템
- ✅ 타입 안전성
- ✅ Hot Reload 지원
- ✅ 설정 패널 제공
- ✅ 반응형 디자인
## 사용법
### 기본 사용법
```tsx
import { ImageDisplayComponent } from "@/lib/registry/components/image-display";
<ImageDisplayComponent
component={{
id: "my-image-display",
type: "widget",
web_type: "file",
position: { x: 100, y: 100, z: 1 },
size: { width: 200, height: 36 },
config: {
// 설정값들
}
}}
isDesignMode={false}
/>
```
### 설정 옵션
| 속성 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| placeholder | string | "" | 플레이스홀더 텍스트 |
| disabled | boolean | false | 비활성화 여부 |
| required | boolean | false | 필수 입력 여부 |
| readonly | boolean | false | 읽기 전용 여부 |
## 이벤트
- `onChange`: 값 변경 시
- `onFocus`: 포커스 시
- `onBlur`: 포커스 해제 시
- `onClick`: 클릭 시
## 스타일링
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
- `variant`: "default" | "outlined" | "filled"
- `size`: "sm" | "md" | "lg"
## 예시
```tsx
// 기본 예시
<ImageDisplayComponent
component={{
id: "sample-image-display",
config: {
placeholder: "입력하세요",
required: true,
variant: "outlined"
}
}}
/>
```
## 개발자 정보
- **생성일**: 2025-09-11
- **CLI 명령어**: `node scripts/create-component.js image-display --category=display --webType=file`
- **경로**: `lib/registry/components/image-display/`
## 관련 문서
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
- [개발자 문서](https://docs.example.com/components/image-display)
@@ -1,53 +0,0 @@
"use client";
import { ImageDisplayConfig } from "./types";
/**
* ImageDisplay
*/
export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
imageUrl: "",
altText: "이미지",
objectFit: "contain",
borderRadius: 8,
showBorder: true,
backgroundColor: "#f9fafb",
placeholder: "이미지 없음",
disabled: false,
required: false,
readonly: false,
variant: "default",
size: "md",
};
/**
* ImageDisplay
*/
export const ImageDisplayConfigSchema = {
imageUrl: { type: "string", default: "" },
altText: { type: "string", default: "이미지" },
objectFit: {
type: "enum",
values: ["contain", "cover", "fill", "none", "scale-down"],
default: "contain",
},
borderRadius: { type: "number", default: 8 },
showBorder: { type: "boolean", default: true },
backgroundColor: { type: "string", default: "#f9fafb" },
placeholder: { type: "string", default: "이미지 없음" },
disabled: { type: "boolean", default: false },
required: { type: "boolean", default: false },
readonly: { type: "boolean", default: false },
variant: {
type: "enum",
values: ["default", "outlined", "filled"],
default: "default",
},
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md",
},
};
@@ -1,46 +0,0 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { ImageDisplayWrapper } from "./ImageDisplayComponent";
import { ImageDisplayConfigPanel } from "./ImageDisplayConfigPanel";
import { ImageDisplayConfig } from "./types";
/**
* ImageDisplay
* image-display
*/
export const ImageDisplayDefinition = createComponentDefinition({
id: "image-display",
name: "이미지 표시",
name_eng: "ImageDisplay Component",
description: "이미지 표시를 위한 이미지 컴포넌트",
category: ComponentCategory.DISPLAY,
web_type: "file",
component: ImageDisplayWrapper,
default_config: {
imageUrl: "",
altText: "이미지",
objectFit: "contain",
borderRadius: 8,
showBorder: true,
backgroundColor: "#f9fafb",
placeholder: "이미지 없음",
},
default_size: { width: 200, height: 200 },
config_panel: ImageDisplayConfigPanel,
icon: "Eye",
tags: [],
version: "1.0.0",
author: "Developer",
documentation: "https://docs.example.com/components/image-display",
});
// 타입 내보내기
export type { ImageDisplayConfig } from "./types";
// 컴포넌트 내보내기
export { ImageDisplayComponent } from "./ImageDisplayComponent";
export { ImageDisplayRenderer } from "./ImageDisplayRenderer";
@@ -1,50 +0,0 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* ImageDisplay
*/
export interface ImageDisplayConfig extends ComponentConfig {
// 이미지 관련 설정
imageUrl?: string;
altText?: string;
objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down";
borderRadius?: number;
showBorder?: boolean;
backgroundColor?: string;
placeholder?: string;
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
// 스타일 관련
variant?: "default" | "outlined" | "filled";
size?: "sm" | "md" | "lg";
// 이벤트 관련
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
}
/**
* ImageDisplay Props
*/
export interface ImageDisplayProps {
id?: string;
name?: string;
value?: any;
config?: ImageDisplayConfig;
className?: string;
style?: React.CSSProperties;
// 이벤트 핸들러
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
}
@@ -1,69 +0,0 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface ImageWidgetConfigPanelProps {
config: any;
onConfigChange: (config: any) => void;
}
/**
*
*/
export function ImageWidgetConfigPanel({ config, onConfigChange }: ImageWidgetConfigPanelProps) {
const handleChange = (key: string, value: any) => {
onConfigChange({
...config,
[key]: value,
});
};
return (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
<CardDescription className="text-xs"> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="maxSize" className="text-xs">
(MB)
</Label>
<Input
id="maxSize"
type="number"
min="1"
max="10"
value={(config.maxSize || 5 * 1024 * 1024) / (1024 * 1024)}
onChange={(e) => handleChange("maxSize", parseInt(e.target.value) * 1024 * 1024)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs">
</Label>
<Input
id="placeholder"
type="text"
value={config.placeholder || "이미지를 업로드하세요"}
onChange={(e) => handleChange("placeholder", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="rounded-md bg-muted p-3 text-xs text-muted-foreground">
<p className="mb-1 font-medium"> :</p>
<p>JPG, PNG, GIF, WebP</p>
</div>
</CardContent>
</Card>
);
}
export default ImageWidgetConfigPanel;
@@ -1,57 +0,0 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ImageWidgetDefinition } from "./index";
import { ImageWidget } from "@/components/screen/widgets/types/ImageWidget";
/**
* ImageWidget
*
*/
export class ImageWidgetRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = ImageWidgetDefinition;
render(): React.ReactElement {
return <ImageWidget {...this.props} renderer={this} />;
}
/**
*
*/
// image 타입 특화 속성 처리
protected getImageWidgetProps() {
const baseProps = this.getWebTypeProps();
// image 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 image 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
ImageWidgetRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
ImageWidgetRenderer.enableHotReload();
}
@@ -1,41 +0,0 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { ImageWidget } from "@/components/screen/widgets/types/ImageWidget";
import { ImageWidgetConfigPanel } from "./ImageWidgetConfigPanel";
/**
* ImageWidget
* image-widget
*/
export const ImageWidgetDefinition = createComponentDefinition({
id: "image-widget",
name: "이미지 위젯 (레거시)",
name_eng: "Image Widget (Legacy)",
description: "이미지 표시 및 업로드 (레거시)",
category: ComponentCategory.INPUT,
web_type: "image",
component: ImageWidget,
default_config: {
type: "image-widget",
web_type: "image",
maxSize: 5 * 1024 * 1024, // 5MB
acceptedFormats: ["image/jpeg", "image/png", "image/gif", "image/webp"],
},
default_size: { width: 200, height: 200 },
config_panel: ImageWidgetConfigPanel,
icon: "Image",
tags: ["image", "upload", "media", "picture", "photo"],
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/image-widget",
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
});
// 컴포넌트 내보내기
export { ImageWidget } from "@/components/screen/widgets/types/ImageWidget";
export { ImageWidgetRenderer } from "./ImageWidgetRenderer";
+4 -8
View File
@@ -24,11 +24,10 @@ import "./select-basic/SelectBasicRenderer";
import "./checkbox-basic/CheckboxBasicRenderer";
import "./radio-basic/RadioBasicRenderer";
import "./date-input/DateInputRenderer";
import "./file-upload/FileUploadRenderer";
import "./image-widget/ImageWidgetRenderer";
// file-upload / image-widget / image-display renderer — Phase D.5 폐기.
// canonical input (FilePicker) 으로 일원화. auto-register import 제거.
import "./slider-basic/SliderBasicRenderer";
import "./toggle-switch/ToggleSwitchRenderer";
import "./image-display/ImageDisplayRenderer";
import "./accordion-basic/AccordionBasicRenderer"; // 컴포넌트 패널에서만 숨김
import "./split-panel-layout2/SplitPanelLayout2Renderer"; // 분할 패널 레이아웃 v2
import "./domain/map/MapRenderer";
@@ -104,7 +103,7 @@ import "./divider/DividerRenderer"; // v2-divider-line + v2-split-line + divider
import "./title/TitleRenderer"; // v2-text-display + text-display 흡수
import "./button/ButtonRenderer"; // v2-button-primary + button-primary + related-data-buttons 흡수
import "./search/SearchRenderer"; // v2-table-search-widget + table-search-widget + autocomplete-search-input 흡수
import "./input/InputRenderer"; // v2-input/select/date/... + 20+ 레거시 입력 컴포넌트 흡수
import "./input/InputRenderer"; // 20+ 레거시 입력 컴포넌트 흡수 (옛 V2 입력/선택 포함, Phase D.2 에서 V2 측 폐기)
import "./stats/StatsRenderer"; // v2-aggregation-widget + v2-status-count + v2-card-display + legacy 흡수
// form 컴포넌트는 롤백됨 (2026-04-11): "폼" 은 별도 컴포넌트가 아닌
// 화면 디자이너의 3뷰 탭(목록/등록 팝업/수정 팝업) 구조로 처리할 예정.
@@ -119,11 +118,8 @@ import "./domain/v2-location-swap-selector/LocationSwapSelectorRenderer";
import "./v2-table-search-widget";
import "./v2-tabs-widget/tabs-component";
import "./v2-category-manager/V2CategoryManagerRenderer";
import "./v2-media/V2MediaRenderer"; // V2 통합 미디어 컴포넌트
// v2-media / v2-file-upload renderer — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수.
import "./domain/v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스케줄러
import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
import "./domain/v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
import "./domain/v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
@@ -5,8 +5,10 @@ import { ComponentRendererProps } from "@/types/component";
import { InputConfig, InputFieldType } from "./types";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { SingleDatePicker, DateTimePicker, TimePicker, RangeDatePicker } from "./pickers";
import { SingleSelectPicker, MultiSelectPicker, RadioPicker, CheckboxListPicker, TogglePicker, TagPicker } from "./select-pickers";
import { SingleSelectPicker, MultiSelectPicker, RadioPicker, CheckboxListPicker, TogglePicker, TagPicker, SwapPicker } from "./select-pickers";
import { NumberingPicker } from "./numbering-picker";
import { FilePicker } from "./file-picker";
import { useOptionLoader } from "./use-option-loader";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
/**
@@ -117,16 +119,12 @@ export const InputComponent: React.FC<InputComponentProps> = ({
// ─── 옛 inputType / web_type 폴백 (점진 폐기 영역) ───────────
if (isValidType(inputType)) return inputType;
if (rawType === "v2-select") return "select";
// (옛 V2 선택 rawType 분기 — Phase D.2 에서 제거. 더 이상 옛 V2 선택 컴포넌트가 생성/렌더되지 않음)
// V2-era 의 inputType="numbering" → code (autonum 의 옛 키)
if (inputType === "numbering" || webType === "numbering") return "code";
if (
inputType === "select" ||
inputType === "code" ||
inputType === "dropdown" ||
inputType === "radio" ||
inputType === "entity"
) return "select";
if (inputType === "entity") return "entity";
if (inputType === "select" || inputType === "code" || inputType === "dropdown" || inputType === "radio")
return "select";
if (inputType === "number" || inputType === "decimal" || inputType === "currency") return "number";
if (inputType === "date") return "date";
if (inputType === "datetime") return "datetime";
@@ -134,14 +132,10 @@ export const InputComponent: React.FC<InputComponentProps> = ({
if (inputType === "daterange") return "daterange";
if (inputType === "textarea") return "textarea";
if (inputType === "checkbox" || inputType === "boolean") return "checkbox";
if (inputType === "file" || inputType === "image") return "file";
if (
webType === "select" ||
webType === "code" ||
webType === "dropdown" ||
webType === "radio" ||
webType === "entity"
) return "select";
if (inputType === "file" || inputType === "image" || inputType === "img") return "file";
if (webType === "entity") return "entity";
if (webType === "select" || webType === "code" || webType === "dropdown" || webType === "radio")
return "select";
if (webType === "number" || webType === "decimal") return "number";
if (webType === "date") return "date";
if (webType === "datetime") return "datetime";
@@ -149,7 +143,8 @@ export const InputComponent: React.FC<InputComponentProps> = ({
if (webType === "daterange") return "daterange";
if (webType === "textarea") return "textarea";
if (webType === "checkbox" || webType === "boolean") return "checkbox";
if (webType === "file" || webType === "image") return "file";
if (webType === "file" || webType === "image" || webType === "img" || webType === "picture" || webType === "photo")
return "file";
return "text";
})();
@@ -169,7 +164,10 @@ export const InputComponent: React.FC<InputComponentProps> = ({
const onFormDataChangeProp = (props as any).onFormDataChange;
const onChangeProp = (props as any).onChange;
const columnName: string | undefined =
(component as any).columnName ?? (component as any).column_name;
(component as any).columnName ??
(component as any).column_name ??
(componentConfig as any).columnName ??
(componentConfig as any).column_name;
// tableName / isEditMode — autonum (code) 채번 hook 용
// `||` 사용: `??` 는 빈 문자열을 통과시켜 뒤 폴백을 막음.
@@ -252,6 +250,26 @@ export const InputComponent: React.FC<InputComponentProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesignMode, autoGenEnabled, autoGenType]);
// ─── 옵션 loader (select 계열) ──────────────────────────────────────
// type 이 select 가 아니더라도 hook 은 항상 호출 (React 규칙). source 가 static
// 이거나 fetch url 이 만들어지지 않으면 hook 내부에서 API 호출 자체 안 일어남.
// user context — runtime 의 `value_type === "user"` 필터 치환에 사용. props 에서
// camelCase / snake_case 둘 다 흡수. 값 없으면 user 필터는 hook 내부에서 skip.
const userContext = {
companyCode: (props as any).companyCode || (props as any).company_code,
userId: (props as any).userId || (props as any).user_id,
deptCode: (props as any).deptCode || (props as any).dept_code,
userName: (props as any).userName || (props as any).user_name,
};
const { options: loadedOptions } = useOptionLoader({
config: componentConfig as any,
tableName,
columnName,
formData: formDataProp,
userContext,
isDesignMode,
});
// ─── DOM props filter (React warning 방지) ────────────────────────────
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
@@ -282,6 +300,11 @@ export const InputComponent: React.FC<InputComponentProps> = ({
userId: _25,
userName: _26,
companyCode: _27,
deptCode: _27a,
user_id: _27b,
user_name: _27c,
company_code: _27d,
dept_code: _27e,
isInModal: _28,
originalData: _30,
_originalData: _31,
@@ -339,7 +362,12 @@ export const InputComponent: React.FC<InputComponentProps> = ({
// container 자체가 input box 역할 — border + radius + background.
// 자식 element 는 자체 border 없이 transparent 로 가득 채움 (이중 박스 방지).
// 단 radio/check 같은 list 형태는 외각 box 자체 불필요 (자체 visual element 가 표시).
const selectMode = (componentConfig as any).mode;
const selectFormat = (componentConfig as any).format;
const isMultiSelect =
type === "select" && (rawType === "multi" || !!(componentConfig as any).multiple);
const selectMode =
(componentConfig as any).mode ||
(isMultiSelect && selectFormat === "list" ? "check" : "dropdown");
const isListLikeMode = type === "select" && (selectMode === "radio" || selectMode === "check");
const containerStyle: React.CSSProperties = {
width: "100%",
@@ -483,15 +511,13 @@ export const InputComponent: React.FC<InputComponentProps> = ({
/>
);
case "select": {
const rawOptions = componentConfig.options ?? [];
const normalizedOptions = rawOptions.map((opt: any) =>
typeof opt === "string"
? { value: opt, label: opt }
: { value: String(opt.value ?? ""), label: String(opt.label ?? opt.value ?? "") },
);
// 옵션은 useOptionLoader 가 결정. static / code / category / distinct / api 모두 처리.
// 로딩 중 / fetch 미발동 (디자인 모드 등) 시에는 static 옵션을 우선 보여준다.
const normalizedOptions = loadedOptions;
const isMulti = rawType === "multi" || !!(componentConfig as any).multiple;
// 기존 옵션 — config.mode (dropdown|combobox|radio|check|tag|toggle)
const mode = (componentConfig as any).mode || "dropdown";
// 기존 옵션 — config.mode (dropdown|combobox|radio|check|tag|swap|toggle)
// multi + 고정 목록은 패널 설명처럼 체크박스 list 를 기본값으로 둔다.
const mode = selectMode;
const searchable = mode === "combobox" || !!(componentConfig as any).searchable;
// multi 분기
@@ -525,6 +551,19 @@ export const InputComponent: React.FC<InputComponentProps> = ({
/>
);
}
if (mode === "swap") {
return (
<SwapPicker
value={arrValue}
onChange={(v) => propagate(v)}
options={normalizedOptions}
maxSelect={(componentConfig as any).maxSelect}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
// dropdown / combobox (기본) — MultiSelectPicker
return (
<MultiSelectPicker
@@ -606,45 +645,99 @@ export const InputComponent: React.FC<InputComponentProps> = ({
</label>
);
}
case "entity":
return (
<div style={{ display: "flex", gap: "4px", width: "100%", height: "100%" }}>
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => propagate(e.target.value)}
{...common}
readOnly
placeholder={placeholder || "검색 팝업에서 선택"}
case "entity": {
// entity 는 검색 모달이 아니라 참조 테이블의 value/label 컬럼을 읽는
// code-name 선택형이다. 명시 검색 UI 는 entity-search-input 쪽 책임.
const entityOptions = loadedOptions;
const entityMode = (componentConfig as any).mode || "combobox";
const entitySearchable =
entityMode === "combobox" || (componentConfig as any).searchable === true;
const isEntityMulti = !!(componentConfig as any).multiple;
if (isEntityMulti) {
const arrValue = Array.isArray(value) ? (value as string[]) : value ? [String(value)] : [];
if (entityMode === "check") {
return (
<CheckboxListPicker
value={arrValue}
onChange={(v) => propagate(v)}
options={entityOptions}
maxSelect={(componentConfig as any).maxSelect}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
if (entityMode === "swap") {
return (
<SwapPicker
value={arrValue}
onChange={(v) => propagate(v)}
options={entityOptions}
maxSelect={(componentConfig as any).maxSelect}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
return (
<MultiSelectPicker
value={arrValue}
onChange={(v) => propagate(v)}
options={entityOptions}
placeholder={placeholder || "선택"}
searchable={entitySearchable}
allowClear={!!(componentConfig as any).allowClear}
maxSelect={(componentConfig as any).maxSelect}
disabled={disabled}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
<button
type="button"
style={{
padding: "5px 10px",
fontSize: "12px",
height: "100%",
border: "1px solid hsl(var(--border))",
background: "hsl(var(--muted))",
borderRadius: "4px",
cursor: disabled || isDesignMode ? "not-allowed" : "pointer",
flexShrink: 0,
}}
disabled={disabled || isDesignMode}
>
🔍
</button>
</div>
);
case "file":
);
}
return (
<input
type="file"
accept={componentConfig.accept}
multiple={componentConfig.multiple}
{...common}
style={{ ...baseInputStyle, padding: "3px 6px" }}
<SingleSelectPicker
value={typeof value === "string" ? value : value != null ? String(value) : ""}
onChange={(v) => propagate(v)}
options={entityOptions}
placeholder={placeholder || "선택"}
searchable={entitySearchable}
allowClear={!!(componentConfig as any).allowClear}
disabled={disabled || isDesignMode}
readonly={readonly}
className="border-0 bg-transparent rounded-none"
/>
);
}
case "file": {
// InvField format: "image" / "file" / "doc". image 이면 자동 preview + image/* accept.
// accept 가 명시되었으면 우선, 아니면 format 으로 기본값 결정.
const fmt = (componentConfig as any).format;
const isImageFormat = fmt === "image";
const defaultAccept = isImageFormat
? "image/*"
: fmt === "doc"
? ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
: "*/*";
const acceptValue = componentConfig.accept || defaultAccept;
const showPreview =
(componentConfig as any).showPreview ?? (isImageFormat || acceptValue.startsWith("image/"));
return (
<FilePicker
value={value as any}
onChange={(v) => propagate(v)}
accept={acceptValue}
multiple={!!componentConfig.multiple}
maxFiles={(componentConfig as any).maxFiles}
disabled={disabled}
readonly={readonly}
placeholder={placeholder}
showPreview={!!showPreview}
className="border-0 bg-transparent rounded-none"
/>
);
}
case "code":
return (
<NumberingPicker
@@ -0,0 +1,249 @@
"use client";
/**
* FilePicker canonical input file picker.
*
* file-upload / v2-file-upload / v2-media / image-widget import
* File + ( / ) + .
* / API phase. InputComponent propagate
* (onChange) , `File | File[]` URL .
*
* Props:
* - value: File | File[] | string | string[] | undefined
* - onChange(value): . multiple ,
* - accept: input[type=file] accept
* - multiple: true
* - maxFiles: 다중 ( )
* - disabled / readonly: /
* - placeholder:
* - showPreview: image (accept image/* )
*
* value :
* - File 객체: file.name URL.createObjectURL preview (image)
* - 문자열: 그대로 URL/ ( path )
*/
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Upload, X, FileText, Image as ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface FilePickerProps {
value?: File | string | Array<File | string> | null;
onChange?: (value: File | string | Array<File | string> | null) => void;
accept?: string;
multiple?: boolean;
maxFiles?: number;
disabled?: boolean;
readonly?: boolean;
placeholder?: string;
showPreview?: boolean;
className?: string;
}
type DisplayItem = {
key: string;
label: string;
previewUrl?: string;
source: File | string;
isImage: boolean;
};
function detectIsImage(name: string | undefined, accept: string | undefined): boolean {
if (!name) return !!accept && accept.startsWith("image/");
const lower = name.toLowerCase();
if (lower.match(/\.(png|jpe?g|gif|webp|svg|bmp|avif)$/)) return true;
return !!accept && accept.startsWith("image/");
}
function toArray(value: FilePickerProps["value"]): Array<File | string> {
if (value == null) return [];
if (Array.isArray(value)) return value.filter((v): v is File | string => !!v);
return [value];
}
function isFileValue(value: File | string): value is File {
return typeof File !== "undefined" && value instanceof File;
}
export const FilePicker = React.forwardRef<HTMLDivElement, FilePickerProps>(
(
{
value,
onChange,
accept,
multiple,
maxFiles,
disabled,
readonly,
placeholder,
showPreview,
className,
},
ref,
) => {
const inputRef = useRef<HTMLInputElement>(null);
const lockEdit = !!(disabled || readonly);
const items = useMemo<DisplayItem[]>(() => {
const arr = toArray(value);
return arr.map((item, idx) => {
if (isFileValue(item)) {
const isImage = detectIsImage(item.name, accept);
return {
key: `${item.name}-${item.size}-${item.lastModified}-${idx}`,
label: item.name,
previewUrl: isImage ? URL.createObjectURL(item) : undefined,
source: item,
isImage,
};
}
// 문자열 (URL 또는 파일명) — 서버 저장된 경로 호환
const str = String(item);
const fileName = str.split("/").pop() || str;
const isImage = detectIsImage(fileName, accept);
return {
key: `${str}-${idx}`,
label: fileName,
previewUrl: isImage ? str : undefined,
source: str,
isImage,
};
});
}, [value, accept]);
// File preview URL revoke (메모리 누수 방지)
useEffect(() => {
const urls = items
.filter((it) => isFileValue(it.source) && it.previewUrl)
.map((it) => it.previewUrl!);
return () => {
urls.forEach((u) => URL.revokeObjectURL(u));
};
}, [items]);
const handleSelectFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
if (lockEdit) return;
const picked = Array.from(e.target.files || []);
if (picked.length === 0) return;
if (!multiple) {
onChange?.(picked[0]);
} else {
const existing = toArray(value);
const combined: Array<File | string> = [...existing, ...picked];
const limited = maxFiles && maxFiles > 0 ? combined.slice(0, maxFiles) : combined;
onChange?.(limited);
}
// 같은 파일 다시 선택 가능하도록 input value 초기화
if (inputRef.current) inputRef.current.value = "";
};
const handleRemove = (key: string) => {
if (lockEdit) return;
if (!multiple) {
onChange?.(null);
return;
}
const next = items.filter((it) => it.key !== key).map((it) => it.source);
onChange?.(next);
};
const wantPreview = showPreview ?? (!!accept && accept.startsWith("image/"));
const atLimit = multiple && maxFiles && items.length >= maxFiles;
const openPicker = () => {
if (lockEdit || atLimit) return;
inputRef.current?.click();
};
return (
<div
ref={ref}
className={cn(
"flex h-full w-full flex-col gap-1 overflow-auto px-1 py-1 text-sm",
lockEdit && "cursor-not-allowed",
className,
)}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={!!multiple}
onChange={handleSelectFiles}
disabled={lockEdit}
className="hidden"
tabIndex={-1}
/>
{items.length === 0 ? (
<button
type="button"
onClick={openPicker}
disabled={lockEdit}
className={cn(
"flex h-full w-full items-center justify-center gap-1.5 rounded-sm border border-dashed border-border bg-background px-2 py-1 text-xs text-muted-foreground",
lockEdit ? "cursor-not-allowed opacity-60" : "hover:bg-muted/40",
)}
>
<Upload className="h-3.5 w-3.5" />
<span>{placeholder || (wantPreview ? "이미지 선택" : "파일 선택")}</span>
</button>
) : (
<>
<div className="flex flex-wrap gap-1.5">
{items.map((it) => (
<div
key={it.key}
className="flex items-center gap-1 rounded-sm border border-border bg-background px-1.5 py-0.5 text-xs"
>
{wantPreview && it.previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={it.previewUrl}
alt={it.label}
className="h-6 w-6 rounded-sm object-cover"
/>
) : it.isImage ? (
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="max-w-[160px] truncate" title={it.label}>
{it.label}
</span>
{!lockEdit && (
<button
type="button"
onClick={() => handleRemove(it.key)}
className="ml-0.5 text-muted-foreground hover:text-foreground"
title="제거"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
{(multiple || items.length === 0) && (
<button
type="button"
onClick={openPicker}
disabled={lockEdit || !!atLimit}
title={atLimit ? `최대 ${maxFiles}개까지` : "추가"}
className={cn(
"flex items-center gap-1 rounded-sm border border-dashed border-border bg-background px-1.5 py-0.5 text-xs text-muted-foreground",
(lockEdit || atLimit) ? "cursor-not-allowed opacity-50" : "hover:bg-muted/40",
)}
>
<Upload className="h-3 w-3" />
<span>{multiple ? "추가" : "교체"}</span>
</button>
)}
</div>
</>
)}
</div>
);
},
);
FilePicker.displayName = "FilePicker";
@@ -13,7 +13,7 @@ import type { InputConfig } from "./types";
* type .
*
* (20+):
* v2-input, v2-select, v2-category-manager, v2-file-upload,
* v2 / (Phase D.2 ), v2-category-manager, v2-file-upload,
* v2-media, v2-numbering-rule, v2-location-swap-selector,
* entity-search-input, autocomplete-search-input,
* text-input, number-input, date-input, select-basic, checkbox-basic,
@@ -10,7 +10,7 @@ import { previewNumberingCode } from "@/lib/api/numberingRule";
* , 릿 `____`
* (prefix span + input + suffix span) .
*
* V2Input.tsx numbering (state/effect/) . :
* (state/effect/) canonical (Phase A.6). :
* - (preview API )
* - props.numberingRuleId (autoGen.options.numberingRuleId )
* - by-column API + getTableColumns fallback
@@ -3,7 +3,7 @@
/**
* Input select picker .
*
* (V2Select.tsx 1350 InvField canonical ):
* ( InvField canonical ):
* - SingleSelectPicker InvField choice.single.list (, )
* - MultiSelectPicker InvField choice.multi.list (TODO Phase B.2)
* - TagPicker InvField choice.multi.tags (TODO Phase B.3)
@@ -13,7 +13,7 @@
*/
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, Search, Check } from "lucide-react";
import { ChevronDown, Search, Check, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SelectOption {
@@ -608,3 +608,203 @@ export const TagPicker = React.forwardRef<HTMLDivElement, TagPickerProps>(
},
);
TagPicker.displayName = "TagPicker";
// ─────────────────────────────────────────────────────────
// SwapPicker — multi 의 mode=swap (양쪽 list 간 이동 UI)
//
// 왼쪽 (available) 가운데 오른쪽 (selected)
// ┌──────────────┐ ┌─────┐ ┌──────────────┐
// │ opt A │ │ → │ │ opt B ✓ │
// │ opt C │ │ ← │ │ opt D │
// │ opt E │ └─────┘ └──────────────┘
// └──────────────┘
//
// 항목 클릭 → 같은 패널 내 highlight (다중 선택). 이동 버튼은 highlight 된 항목들을
// 반대편으로 이동. 이동 후 highlight 자동 해제.
//
// 순서 정책:
// - selected 순서가 value 배열 순서. left→right 이동 시 끝에 append.
// - right→left 이동 시 항목 제거 (available 표시 순서는 options 배열 순서를 유지).
// - options 배열에 없는 value 가 들어와도 selected 에 표시 (label = value fallback).
//
// disabled / readonly: 이동 버튼 + 항목 클릭 모두 비활성. maxSelect: 좌→우 이동
// 가능 한도 초과분은 잘림.
// ─────────────────────────────────────────────────────────
interface SwapPickerProps {
value?: string[];
onChange?: (values: string[]) => void;
options: SelectOption[];
maxSelect?: number;
disabled?: boolean;
readonly?: boolean;
className?: string;
/** 왼쪽 패널 헤더 라벨. 기본 "선택 가능" */
availableLabel?: string;
/** 오른쪽 패널 헤더 라벨. 기본 "선택됨" */
selectedLabel?: string;
}
export const SwapPicker = React.forwardRef<HTMLDivElement, SwapPickerProps>(
(
{
value,
onChange,
options,
maxSelect,
disabled,
readonly,
className,
availableLabel = "선택 가능",
selectedLabel = "선택됨",
},
ref,
) => {
const selectedValues: string[] = Array.isArray(value) ? value : value ? [String(value)] : [];
const selectedSet = new Set(selectedValues);
// option lookup — options 에 없는 value 도 label = value 로 fallback.
const optionLookup = new Map<string, SelectOption>(options.map((o) => [o.value, o]));
const getLabel = (v: string): string => optionLookup.get(v)?.label ?? v;
// available 패널 (options 순서 보존, selected 제외)
const available = options.filter((o) => !selectedSet.has(o.value));
// selected 패널 (value 배열 순서 그대로)
const selected = selectedValues.map((v) => ({
value: v,
label: getLabel(v),
disabled: optionLookup.get(v)?.disabled,
}));
// 패널 내 highlight 상태
const [highlightLeft, setHighlightLeft] = useState<Set<string>>(new Set());
const [highlightRight, setHighlightRight] = useState<Set<string>>(new Set());
const lockEdit = !!(disabled || readonly);
const toggleHighlight = (side: "left" | "right", v: string) => {
if (lockEdit) return;
const setter = side === "left" ? setHighlightLeft : setHighlightRight;
setter((prev) => {
const next = new Set(prev);
if (next.has(v)) next.delete(v);
else next.add(v);
return next;
});
};
const moveToSelected = () => {
if (lockEdit) return;
if (highlightLeft.size === 0) return;
const additions = available
.map((o) => o.value)
.filter((v) => highlightLeft.has(v) && !optionLookup.get(v)?.disabled);
if (additions.length === 0) {
setHighlightLeft(new Set());
return;
}
let next = [...selectedValues, ...additions];
if (maxSelect && next.length > maxSelect) {
next = next.slice(0, maxSelect);
}
onChange?.(next);
setHighlightLeft(new Set());
};
const moveToAvailable = () => {
if (lockEdit) return;
if (highlightRight.size === 0) return;
const next = selectedValues.filter((v) => !highlightRight.has(v));
onChange?.(next);
setHighlightRight(new Set());
};
const canMoveRight = !lockEdit && highlightLeft.size > 0;
const canMoveLeft = !lockEdit && highlightRight.size > 0;
const atLimit = !!maxSelect && selectedValues.length >= maxSelect;
const renderPanel = (
side: "left" | "right",
items: Array<{ value: string; label: string; disabled?: boolean }>,
headerLabel: string,
highlight: Set<string>,
) => (
<div className="flex h-full min-w-0 flex-1 flex-col rounded-sm border border-border bg-background">
<div className="border-b border-border px-2 py-1 text-[11px] font-medium text-muted-foreground">
{headerLabel}
<span className="ml-1 text-[10px] text-muted-foreground/70">({items.length})</span>
</div>
<ul className="flex-1 overflow-auto py-1 text-sm">
{items.length === 0 ? (
<li className="px-2 py-1 text-xs text-muted-foreground"> </li>
) : (
items.map((it) => {
const optDisabled = lockEdit || it.disabled;
const isHi = highlight.has(it.value);
return (
<li
key={it.value}
onClick={() => !optDisabled && toggleHighlight(side, it.value)}
className={cn(
"cursor-pointer select-none truncate px-2 py-0.5",
isHi && "bg-primary/15 text-primary",
!isHi && "hover:bg-muted/50",
optDisabled && "cursor-not-allowed opacity-60",
)}
title={it.label}
>
{it.label}
</li>
);
})
)}
</ul>
</div>
);
return (
<div
ref={ref}
className={cn(
"flex h-full w-full items-stretch gap-1.5 px-1 py-1",
lockEdit && "cursor-not-allowed",
className,
)}
>
{renderPanel("left", available, availableLabel, highlightLeft)}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={moveToSelected}
disabled={!canMoveRight || atLimit}
title={atLimit ? `최대 ${maxSelect}개까지` : "선택"}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-sm border border-border bg-background",
(!canMoveRight || atLimit) && "cursor-not-allowed opacity-40",
canMoveRight && !atLimit && "hover:bg-muted",
)}
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={moveToAvailable}
disabled={!canMoveLeft}
title="선택 해제"
className={cn(
"flex h-6 w-6 items-center justify-center rounded-sm border border-border bg-background",
!canMoveLeft && "cursor-not-allowed opacity-40",
canMoveLeft && "hover:bg-muted",
)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
</div>
{renderPanel("right", selected, selectedLabel, highlightRight)}
</div>
);
},
);
SwapPicker.displayName = "SwapPicker";
@@ -11,7 +11,7 @@ import type { FieldRef, FieldOption } from "@/types/invyone-component";
* .
*
* (20+):
* v2-input / v2-select / v2-category-manager / v2-file-upload /
* v2 / (Phase D.2 ) / v2-category-manager / v2-file-upload /
* v2-media / v2-numbering-rule / entity-search-input / v2-location-swap-selector /
* text-input / number-input / date-input / select-basic / checkbox-basic /
* radio-basic / toggle-switch / slider-basic / textarea-basic / file-upload /
@@ -87,10 +87,14 @@ export interface InputConfig extends ComponentConfig {
// ─── file ────────────────────────────────────────────────────────
/** 파일 허용 확장자 */
/** 파일 허용 확장자. 예: "image/*", ".pdf,.doc" */
accept?: string;
/** 다중 선택 */
multiple?: boolean;
/** 최대 파일 개수 (multiple=true 일 때만 유의). 미지정 시 무제한 */
maxFiles?: number;
/** image/* 또는 explicit 옵션일 때 미리보기 thumbnail 노출 */
showPreview?: boolean;
// ─── date / datetime / time / daterange ─────────────────────────
@@ -0,0 +1,530 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "@/lib/api/client";
import type { FieldOption, FieldRef } from "@/types/invyone-component";
/**
* select-pickers SelectOption .
*
* FieldOption `string | {value,label}` union picker
* . .
*/
export interface LoadedOption {
value: string;
label: string;
}
/**
* use-option-loader
*
* InputComponent select (single / multi / radio / check / tag)
* 5 source .
*
* static config.options
* code (/common-codes/categories/{group}/options) code_group / codeCategory
* category (/table-categories/{table}/{column}/values)
* distinct distinct (/entity/{table}/distinct/{column})
* api endpoint
*
* input canonical . apiClient .
*
* isDesignMode === true static API
* skip. API .
*
* url module-scoped Map ( ). race
* effect cancelled flag .
*
* / cascade code source hierarchical (parentField value
* ) . swap / cascade TODO.
*
* :
* const { options, loading } = useOptionLoader({
* config: componentConfig,
* tableName,
* columnName,
* formData,
* isDesignMode,
* });
*/
/**
* (canonical) InvFieldConfigPanel UI
* . V2 / canonical .
*
* value_type:
* static value
* field field_ref , runtime
* user user_field (companyCode ), runtime
*/
export interface OptionFilter {
column: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like" | "isNull" | "isNotNull";
value_type?: "static" | "field" | "user";
value?: unknown;
field_ref?: string;
user_field?: "companyCode" | "userId" | "deptCode" | "userName";
}
export type OptionSource =
| "static"
| "code"
| "category"
| "entity"
| "distinct"
| "select"
| "api"
| "db";
interface RawOptionItem {
value?: unknown;
label?: unknown;
// camelCase (legacy / 일부 endpoint)
valueCode?: string;
valueLabel?: string;
// snake_case (백엔드 표준 응답 — table-categories / common-codes hierarchy)
value_code?: string;
value_label?: string;
children?: RawOptionItem[];
}
export interface OptionLoaderConfig {
source?: string;
options?: Array<FieldOption | { value: string; label?: string }> | undefined;
codeGroup?: string;
codeCategory?: string;
categoryTable?: string;
categoryColumn?: string;
entityTable?: string;
entityValueColumn?: string;
entityLabelColumn?: string;
ref?: FieldRef;
table?: string;
valueColumn?: string;
labelColumn?: string;
apiEndpoint?: string;
hierarchical?: boolean;
parentField?: string;
/**
* (canonical) runtime formData / user context
* `filters` query JSON .
*/
filters?: OptionFilter[];
}
/**
* `value_type === "user"` .
*/
export interface OptionUserContext {
companyCode?: string;
userId?: string;
deptCode?: string;
userName?: string;
}
export interface UseOptionLoaderArgs {
config: OptionLoaderConfig & Record<string, any>;
tableName?: string;
columnName?: string;
formData?: Record<string, any>;
/** `value_type === "user"` 필터 치환용 사용자 메타. 미지정 시 user 필터는 skip. */
userContext?: OptionUserContext;
isDesignMode?: boolean;
}
export interface UseOptionLoaderResult {
options: LoadedOption[];
loading: boolean;
}
// FieldOption (string | object) 도 받을 수 있게 normalizeOptions 가 처리.
// FieldOption 은 import 만 하고 직접 시그니처에는 노출하지 않는다.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _FieldOptionSentinel = FieldOption;
// 모듈 스코프 캐시 — url 단위. apiClient 의 baseURL 이 같다는 전제.
// 캐시 무효화 트리거가 필요한 경우 입력 컴포넌트가 unmount/remount 되어야 함.
const responseCache = new Map<string, LoadedOption[]>();
function normalizeOptions(raw: any[]): LoadedOption[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item): LoadedOption | null => {
if (item == null) return null;
if (typeof item === "string") return { value: item, label: item };
if (typeof item === "object") {
// 값 키 우선순위: value / code / id (보편)
// + valueCode / value_code (table-categories tree leaf)
// + codeValue / code_value (common-codes hierarchy)
const v =
item.value ??
item.code ??
item.id ??
item.valueCode ??
item.value_code ??
item.codeValue ??
item.code_value;
// 라벨 키 우선순위: label / name (보편)
// + valueLabel / value_label (table-categories)
// + codeName / code_name (common-codes hierarchy)
const l =
item.label ??
item.name ??
item.valueLabel ??
item.value_label ??
item.codeName ??
item.code_name ??
v;
if (v == null || String(v) === "") return null;
return { value: String(v), label: l != null ? String(l) : String(v) };
}
return null;
})
.filter((x): x is LoadedOption => x !== null);
}
function flattenCategoryTree(items: RawOptionItem[], depth = 0): LoadedOption[] {
const out: LoadedOption[] = [];
for (const item of items) {
// 백엔드 응답은 snake_case (value_code/value_label) — camelCase 호환 유지.
const code =
item.valueCode ??
item.value_code ??
(item.value != null ? String(item.value) : "");
if (!code) continue;
const labelRaw =
item.valueLabel ??
item.value_label ??
(item.label != null ? String(item.label) : code);
const prefix = depth > 0 ? "   ".repeat(depth) + "└ " : "";
out.push({ value: code, label: prefix + labelRaw });
if (Array.isArray(item.children) && item.children.length > 0) {
out.push(...flattenCategoryTree(item.children, depth + 1));
}
}
return out;
}
function isCanonicalEmpty(v: any): boolean {
return v === undefined || v === null || v === "";
}
/** ResolvedFilter — runtime 에 value 가 실제 값으로 치환된 후의 형태. */
interface ResolvedFilter {
column: string;
operator: OptionFilter["operator"];
/** isNull/isNotNull 은 value 없이 전달. in/notIn 은 배열. */
value?: unknown;
}
/**
* OptionFilter runtime .
* - column skip
* - value_type=field: formData[field_ref] . skip
* ( isNull/isNotNull value )
* - value_type=user: userContext[user_field] . skip
* - in/notIn: "a,b,c" trim
* - isNull/isNotNull: value
*/
function resolveFilters(
filters: OptionFilter[] | undefined,
formData: Record<string, any> | undefined,
userContext: OptionUserContext | undefined,
): ResolvedFilter[] {
if (!Array.isArray(filters) || filters.length === 0) return [];
const out: ResolvedFilter[] = [];
for (const f of filters) {
const column = typeof f?.column === "string" ? f.column.trim() : "";
if (!column) continue;
const op = f.operator;
// isNull / isNotNull 은 value 없이 통과
if (op === "isNull" || op === "isNotNull") {
out.push({ column, operator: op });
continue;
}
let raw: unknown;
const vt = f.value_type || "static";
if (vt === "static") {
raw = f.value;
} else if (vt === "field") {
if (!f.field_ref) continue;
raw = formData?.[f.field_ref];
if (isCanonicalEmpty(raw)) continue;
} else if (vt === "user") {
if (!f.user_field) continue;
raw = userContext?.[f.user_field];
if (isCanonicalEmpty(raw)) continue;
} else {
raw = f.value;
}
// in / notIn 정규화: 배열 또는 콤마 구분 문자열
if (op === "in" || op === "notIn") {
let arr: unknown[];
if (Array.isArray(raw)) {
arr = raw;
} else if (typeof raw === "string") {
arr = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
} else if (isCanonicalEmpty(raw)) {
continue;
} else {
arr = [raw];
}
if (arr.length === 0) continue;
out.push({ column, operator: op, value: arr });
continue;
}
// 그 외 operator 는 빈 값 skip
if (isCanonicalEmpty(raw)) continue;
out.push({ column, operator: op, value: raw });
}
return out;
}
function resolveSource(config: OptionLoaderConfig): OptionSource {
const raw = (config.source || "").toString().toLowerCase();
if (raw === "static" || raw === "code" || raw === "category" || raw === "entity" ||
raw === "distinct" || raw === "select" || raw === "api" || raw === "db") {
return raw as OptionSource;
}
// source 미지정 — 기존 데이터를 보고 추정 (옛 키 호환 최소)
if (Array.isArray(config.options) && config.options.length > 0) return "static";
if (config.codeGroup || config.codeCategory) return "code";
if (config.categoryTable && config.categoryColumn) return "category";
if (config.entityTable || config.ref?.table) return "entity";
if (config.apiEndpoint) return "api";
return "static";
}
export function useOptionLoader({
config,
tableName,
columnName,
formData,
userContext,
isDesignMode,
}: UseOptionLoaderArgs): UseOptionLoaderResult {
const source = resolveSource(config);
// static options 는 외부 의존성 없이 즉시 결정.
const staticOptions = useMemo<LoadedOption[]>(() => {
return normalizeOptions(config.options as any[] | undefined ?? []);
}, [config.options]);
// runtime 치환된 filter JSON — fetchUrl 의 dep 으로 stable string 사용해서
// formData / filters 객체 reference 변경에 의한 effect 폭주 방지.
// 결과가 빈 배열이면 undefined 로 두어 URL 에 filters query 자체를 안 붙임.
const resolvedFiltersJson = useMemo<string | undefined>(() => {
const resolved = resolveFilters(config.filters, formData, userContext);
if (resolved.length === 0) return undefined;
return JSON.stringify(resolved);
}, [
config.filters,
formData,
userContext?.companyCode,
userContext?.userId,
userContext?.deptCode,
userContext?.userName,
]);
// 계층 코드의 parent 값 (다른 폼 필드 참조)
const parentValue = useMemo<string | undefined>(() => {
if (!config.hierarchical || !config.parentField) return undefined;
const v = formData?.[config.parentField];
if (v == null || v === "") return undefined;
return String(v);
}, [config.hierarchical, config.parentField, formData]);
// API 호출이 필요한 source 인지
const needsFetch =
source !== "static" &&
// 디자인 모드에서는 캔버스 드래그/리사이즈 등으로 effect 가 자주 트리거되므로
// API 호출을 통째로 skip 한다. 운영 모드에서만 fetch.
!isDesignMode;
// fetch url 결정 (memo 로 effect dep 안정화)
const fetchUrl = useMemo<string | null>(() => {
if (!needsFetch) return null;
// filters query 헬퍼 — 이미 resolvedFiltersJson 으로 stable string 화 되어 있음.
// 백엔드 표준: `filters=` URL-encoded JSON. 빈 결과면 query 자체를 안 붙임.
const filtersQuery = resolvedFiltersJson
? `filters=${encodeURIComponent(resolvedFiltersJson)}`
: "";
const appendFilters = (url: string): string => {
if (!filtersQuery) return url;
return url + (url.includes("?") ? "&" : "?") + filtersQuery;
};
if (source === "code") {
const group = config.codeGroup || config.codeCategory;
if (!group) return null;
if (config.hierarchical) {
// 백엔드 endpoint 는 `/hierarchy?parentCodeValue=...` (children 은 미존재)
const q = parentValue ? `?parentCodeValue=${encodeURIComponent(parentValue)}` : "";
// filters 는 공통코드 endpoint 가 처리 안 함 → query 미추가 (TODO: backend 지원 시 활성화)
return `/common-codes/categories/${encodeURIComponent(group)}/hierarchy${q}`;
}
return `/common-codes/categories/${encodeURIComponent(group)}/options`;
}
if (source === "category") {
const t = config.categoryTable || tableName;
const c = config.categoryColumn || columnName;
if (!t || !c) return null;
// filters 는 table-categories endpoint 가 처리 안 함 → query 미추가
return `/table-categories/${encodeURIComponent(t)}/${encodeURIComponent(c)}/values`;
}
if (source === "distinct" || source === "select") {
// tableName 필수, columnName 은 가상컬럼 (comp_*) 제외
if (!tableName || !columnName) return null;
if (columnName.startsWith("comp_")) return null;
return appendFilters(
`/entity/${encodeURIComponent(tableName)}/distinct/${encodeURIComponent(columnName)}`,
);
}
if (source === "api") {
const ep = config.apiEndpoint;
if (!ep) return null;
// 외부 endpoint 는 filter 스펙이 정의되어 있지 않으므로 통과 (사용자 정의 URL 그대로)
return ep;
}
if (source === "entity") {
// entity 는 DB FK 를 직접 걸 수 없는 경우 화면 설정에서 참조 테이블과
// value/label 컬럼을 지정해 code-name 옵션으로 읽는다.
const ref = config.ref;
const t = config.entityTable || ref?.table;
if (!t) return null;
const v = config.entityValueColumn || ref?.valueColumn || "id";
const l = config.entityLabelColumn || ref?.displayColumn || "name";
return appendFilters(
`/entity/${encodeURIComponent(t)}/options?value=${encodeURIComponent(v)}&label=${encodeURIComponent(l)}`,
);
}
if (source === "db") {
// legacy V2 source. table/value/label 컬럼으로 옵션 펼치기.
const t = config.table;
if (!t) return null;
const v = config.valueColumn || "id";
const l = config.labelColumn || "name";
return appendFilters(
`/entity/${encodeURIComponent(t)}/options?value=${encodeURIComponent(v)}&label=${encodeURIComponent(l)}`,
);
}
return null;
}, [
needsFetch,
source,
config.codeGroup,
config.codeCategory,
config.hierarchical,
parentValue,
config.categoryTable,
config.categoryColumn,
config.apiEndpoint,
config.table,
config.valueColumn,
config.labelColumn,
// entity 분기 의존성 — entityTable / entityValueColumn / entityLabelColumn 변경 시
// 옵션 재로드 되도록 명시. ref 는 객체 자체가 매번 바뀌면 effect 폭주하므로
// 내부 키만 dep 으로 추출.
config.entityTable,
config.entityValueColumn,
config.entityLabelColumn,
config.ref?.table,
config.ref?.valueColumn,
config.ref?.displayColumn,
// 필터 변경 시 URL 재생성 → cache key 자동 신규화
resolvedFiltersJson,
tableName,
columnName,
]);
const [fetched, setFetched] = useState<LoadedOption[] | null>(() => {
return fetchUrl ? (responseCache.get(fetchUrl) ?? null) : null;
});
const [loading, setLoading] = useState(false);
const reqIdRef = useRef(0);
useEffect(() => {
if (!fetchUrl) {
// fetch 가 필요 없는 source / config 미충족 → 이전 fetch 의 loading 상태가
// 남아있지 않도록 명시적으로 false. race guard 차원에서 reqId 도 +1.
reqIdRef.current++;
setFetched(null);
setLoading(false);
return;
}
const cached = responseCache.get(fetchUrl);
if (cached) {
// cache hit — 비동기 분기로 빠지지 않으므로 finally 가 안 돌아간다.
// loading 이 true 인 상태에서 cache hit 가 발생하면 stuck 위험 → 직접 false.
reqIdRef.current++;
setFetched(cached);
setLoading(false);
return;
}
let cancelled = false;
const myReq = ++reqIdRef.current;
setLoading(true);
apiClient
.get(fetchUrl)
.then((response) => {
if (cancelled || myReq !== reqIdRef.current) return;
const data = response?.data;
let opts: LoadedOption[] = [];
// 응답 shape 적응
if (Array.isArray(data)) {
opts = normalizeOptions(data);
} else if (data && typeof data === "object") {
if (data.success && Array.isArray(data.data)) {
// category 트리 vs flat 분기
if (source === "category") {
opts = flattenCategoryTree(data.data as RawOptionItem[]);
} else {
opts = normalizeOptions(data.data);
}
} else if (Array.isArray((data as any).options)) {
opts = normalizeOptions((data as any).options);
}
}
responseCache.set(fetchUrl, opts);
setFetched(opts);
})
.catch((err) => {
if (cancelled || myReq !== reqIdRef.current) return;
console.warn("[useOptionLoader] load failed:", fetchUrl, err);
setFetched([]);
})
.finally(() => {
if (cancelled || myReq !== reqIdRef.current) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [fetchUrl, source]);
// 최종 옵션 결정
// - source = static : staticOptions
// - source = api 등 외부 : fetched 가 null 이면 (아직 로딩 전) static fallback,
// fetched 가 있으면 fetched 우선
// - isDesignMode 에서는 fetch 자체를 skip 하므로 staticOptions 만 사용
const options = useMemo<LoadedOption[]>(() => {
if (source === "static") return staticOptions;
if (isDesignMode) return staticOptions;
if (fetched == null) return staticOptions; // 첫 로드 전 — static 으로 silhouette
return fetched;
}, [source, isDesignMode, staticOptions, fetched]);
return { options, loading };
}
@@ -445,9 +445,7 @@ export function AggregationWidgetConfigPanel({
type.includes("radio") ||
type.includes("textarea") ||
type.includes("number") ||
// v2-input, v2-select 등 (v2-repeater 등은 제외)
type === "v2-input" ||
type === "v2-select" ||
// (V2 입력/선택은 Phase D.2 에서 폐기 — substring match 로도 안 잡힘. canonical input 은 includes("input") 으로 포함)
type === "v2-hierarchy"
);
@@ -833,7 +833,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
});
}
} else if (formData) {
// DataProvider로 등록되지 않은 컴포넌트(v2-select 등)는 formData에서 값을 가져옴
// DataProvider로 등록되지 않은 컴포넌트는 formData에서 값을 가져옴
const comp = allComponents?.find((c: any) => c.id === additionalSource.componentId);
const columnName =
comp?.columnName ||
@@ -1,494 +0,0 @@
"use client";
import React, { useState, useRef } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileInfo, FileUploadConfig } from "./types";
import {
Upload,
Download,
Trash2,
Eye,
File,
FileText,
Image as ImageIcon,
Video,
Music,
Archive,
Presentation,
X,
Star,
ZoomIn,
ZoomOut,
RotateCcw,
} from "lucide-react";
import { formatFileSize } from "@/lib/utils";
import { FileViewerModal } from "./FileViewerModal";
interface FileManagerModalProps {
isOpen: boolean;
onClose: () => void;
uploadedFiles: FileInfo[];
onFileUpload: (files: File[]) => Promise<void>;
onFileDownload: (file: FileInfo) => void;
onFileDelete: (file: FileInfo) => void;
onFileView: (file: FileInfo) => void;
onSetRepresentative?: (file: FileInfo) => void; // 대표 이미지 설정 콜백
config: FileUploadConfig;
isDesignMode?: boolean;
}
export const FileManagerModal: React.FC<FileManagerModalProps> = ({
isOpen,
onClose,
uploadedFiles,
onFileUpload,
onFileDownload,
onFileDelete,
onFileView,
onSetRepresentative,
config,
isDesignMode = false,
}) => {
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null); // 선택된 파일 (좌측 미리보기용)
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null); // 이미지 미리보기 URL
const [zoomLevel, setZoomLevel] = useState(1); // 🔍 확대/축소 레벨
const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 }); // 🖱️ 이미지 위치
const [isDragging, setIsDragging] = useState(false); // 🖱️ 드래그 중 여부
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); // 🖱️ 드래그 시작 위치
const fileInputRef = useRef<HTMLInputElement>(null);
const imageContainerRef = useRef<HTMLDivElement>(null);
// 파일 아이콘 가져오기
const getFileIcon = (fileExt: string) => {
const ext = fileExt.toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
return <ImageIcon className="h-5 w-5 text-primary" />;
} else if (["pdf", "doc", "docx", "txt", "rtf"].includes(ext)) {
return <FileText className="h-5 w-5 text-destructive" />;
} else if (["xls", "xlsx", "csv"].includes(ext)) {
return <FileText className="h-5 w-5 text-emerald-500" />;
} else if (["ppt", "pptx"].includes(ext)) {
return <Presentation className="h-5 w-5 text-amber-500" />;
} else if (["mp4", "avi", "mov", "webm"].includes(ext)) {
return <Video className="h-5 w-5 text-purple-500" />;
} else if (["mp3", "wav", "ogg"].includes(ext)) {
return <Music className="h-5 w-5 text-pink-500" />;
} else if (["zip", "rar", "7z"].includes(ext)) {
return <Archive className="h-5 w-5 text-amber-500" />;
} else {
return <File className="h-5 w-5 text-muted-foreground" />;
}
};
// 파일 업로드 핸들러
const handleFileUpload = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
setUploading(true);
try {
const fileArray = Array.from(files);
await onFileUpload(fileArray);
console.log("✅ FileManagerModal: 파일 업로드 완료");
} catch (error) {
console.error("❌ FileManagerModal: 파일 업로드 오류:", error);
} finally {
setUploading(false);
console.log("🔄 FileManagerModal: 업로드 상태 초기화");
}
};
// 드래그 앤 드롭 핸들러
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (config.disabled || isDesignMode) return;
const files = e.dataTransfer.files;
handleFileUpload(files);
};
// 파일 선택 핸들러
const handleFileSelect = () => {
if (config.disabled || isDesignMode) return;
fileInputRef.current?.click();
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
handleFileUpload(files);
}
// 입력값 초기화
e.target.value = "";
};
// 파일 뷰어 핸들러
const handleFileViewInternal = (file: FileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
};
const handleViewerClose = () => {
setIsViewerOpen(false);
setViewerFile(null);
};
// 파일 클릭 시 미리보기 로드
const handleFileClick = async (file: FileInfo) => {
setSelectedFile(file);
setZoomLevel(1); // 🔍 파일 선택 시 확대/축소 레벨 초기화
setImagePosition({ x: 0, y: 0 }); // 🖱️ 이미지 위치 초기화
// 이미지 파일인 경우 미리보기 로드
// 🔑 점(.)을 제거하고 확장자만 비교
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
const ext = file.fileExt.toLowerCase().replace(".", "");
if (imageExtensions.includes(ext) || (file as any).isImage) {
try {
// 이전 Blob URL 해제
if (previewImageUrl) {
URL.revokeObjectURL(previewImageUrl);
}
// 🔑 항상 apiClient를 통해 Blob 다운로드 (Docker 환경에서 상대 경로 문제 방지)
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/preview/${file.objid}`, {
responseType: "blob",
});
const blob = new Blob([response.data]);
const blobUrl = URL.createObjectURL(blob);
setPreviewImageUrl(blobUrl);
} catch (error) {
console.error("이미지 로드 실패:", error);
setPreviewImageUrl(null);
}
} else {
setPreviewImageUrl(null);
}
};
// 컴포넌트 언마운트 시 Blob URL 해제
React.useEffect(() => {
return () => {
if (previewImageUrl) {
URL.revokeObjectURL(previewImageUrl);
}
};
}, [previewImageUrl]);
// 🔑 모달이 열릴 때 첫 번째 파일을 자동으로 선택하고 확대/축소 레벨 초기화
React.useEffect(() => {
if (isOpen) {
setZoomLevel(1); // 🔍 모달 열릴 때 확대/축소 레벨 초기화
setImagePosition({ x: 0, y: 0 }); // 🖱️ 이미지 위치 초기화
if (uploadedFiles.length > 0 && !selectedFile) {
const firstFile = uploadedFiles[0];
handleFileClick(firstFile);
}
}
}, [isOpen, uploadedFiles, selectedFile]);
// 🖱️ 마우스 드래그 핸들러
const handleMouseDown = (e: React.MouseEvent) => {
if (zoomLevel > 1) {
setIsDragging(true);
setDragStart({ x: e.clientX - imagePosition.x, y: e.clientY - imagePosition.y });
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging && zoomLevel > 1) {
setImagePosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
// 🔍 확대/축소 레벨이 1로 돌아가면 위치도 초기화
React.useEffect(() => {
if (zoomLevel <= 1) {
setImagePosition({ x: 0, y: 0 });
}
}, [zoomLevel]);
return (
<>
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-h-[90vh] w-[1400px] max-w-[95vw] overflow-hidden [&>button]:hidden">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<DialogTitle className="text-lg font-semibold"> ({uploadedFiles.length})</DialogTitle>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-muted" onClick={onClose} title="닫기">
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<div className="flex h-[75vh] flex-col space-y-3">
{/* 파일 업로드 영역 - readonly/disabled이면 숨김 */}
{!isDesignMode && !config.readonly && !config.disabled && (
<div
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-primary/60 bg-primary/10" : "border-input"} hover:border-input ${uploading ? "opacity-75" : ""} `}
onClick={() => {
fileInputRef.current?.click();
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
multiple={config.multiple}
accept={config.accept}
onChange={handleFileInputChange}
className="hidden"
/>
{uploading ? (
<div className="flex items-center justify-center gap-2">
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
<span className="text-sm font-medium text-primary"> ...</span>
</div>
) : (
<div className="flex items-center justify-center gap-3">
<Upload className="h-6 w-6 text-muted-foreground/70" />
<p className="text-sm font-medium text-foreground"> </p>
</div>
)}
</div>
)}
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
<div className="flex min-h-0 flex-1 gap-4">
{/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */}
{(config.showPreview !== false) && <div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-border bg-foreground">
{/* 확대/축소 컨트롤 */}
{selectedFile && previewImageUrl && (
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel((prev) => Math.max(0.25, prev - 0.25))}
disabled={zoomLevel <= 0.25}
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="min-w-[50px] text-center text-xs text-white">{Math.round(zoomLevel * 100)}%</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel((prev) => Math.min(4, prev + 0.25))}
disabled={zoomLevel >= 4}
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(1)}
>
<RotateCcw className="h-4 w-4" />
</Button>
</div>
)}
{/* 이미지 미리보기 영역 - 마우스 휠로 확대/축소, 드래그로 이동 */}
<div
ref={imageContainerRef}
className={`flex flex-1 items-center justify-center overflow-hidden p-4 ${
zoomLevel > 1 ? (isDragging ? "cursor-grabbing" : "cursor-grab") : "cursor-zoom-in"
}`}
onWheel={(e) => {
if (selectedFile && previewImageUrl) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoomLevel((prev) => Math.min(4, Math.max(0.25, prev + delta)));
}
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{selectedFile && previewImageUrl ? (
<img
src={previewImageUrl}
alt={selectedFile.realFileName}
className="transition-transform duration-100 select-none"
style={{
transform: `translate(${imagePosition.x}px, ${imagePosition.y}px) scale(${zoomLevel})`,
transformOrigin: "center center",
}}
draggable={false}
/>
) : selectedFile ? (
<div className="flex flex-col items-center text-muted-foreground/70">
{getFileIcon(selectedFile.fileExt)}
<p className="mt-2 text-sm"> </p>
</div>
) : (
<div className="flex flex-col items-center text-muted-foreground/70">
<ImageIcon className="mb-2 h-16 w-16" />
<p className="text-sm"> </p>
</div>
)}
</div>
{/* 파일 정보 바 */}
{selectedFile && (
<div className="truncate bg-black/60 px-3 py-2 text-center text-xs text-white">
{selectedFile.realFileName}
</div>
)}
</div>}
{/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */}
{(config.showFileList !== false) && <div className={`flex shrink-0 flex-col overflow-hidden rounded-lg border border-border ${config.showPreview !== false ? "w-[400px]" : "flex-1"}`}>
<div className="border-b border-border bg-muted p-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground"> </h3>
{uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-3">
{uploadedFiles.length > 0 ? (
<div className="space-y-2">
{uploadedFiles.map((file) => (
<div
key={file.objid}
className={`flex cursor-pointer items-center space-x-3 rounded-lg p-2 transition-colors ${selectedFile?.objid === file.objid ? "border border-primary/20 bg-primary/10" : "bg-muted hover:bg-muted"} `}
onClick={() => handleFileClick(file)}
>
<div className="flex-shrink-0">{getFileIcon(file.fileExt)}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">{file.realFileName}</span>
{file.isRepresentative && (
<Badge variant="default" className="h-5 px-1.5 text-xs">
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{config.showFileSize !== false && <>{formatFileSize(file.fileSize)} </>}{file.fileExt.toUpperCase()}
</p>
</div>
<div className="flex items-center space-x-1">
{onSetRepresentative && (
<Button
variant={file.isRepresentative ? "default" : "ghost"}
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onSetRepresentative(file);
}}
title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"}
>
<Star className={`h-3 w-3 ${file.isRepresentative ? "fill-white" : ""}`} />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
handleFileViewInternal(file);
}}
title="미리보기"
>
<Eye className="h-3 w-3" />
</Button>
{config.allowDownload !== false && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onFileDownload(file);
}}
title="다운로드"
>
<Download className="h-3 w-3" />
</Button>
)}
{!isDesignMode && config.allowDelete !== false && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onFileDelete(file);
}}
title="삭제"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<File className="mb-3 h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="mt-1 text-xs text-muted-foreground">
{isDesignMode
? "디자인 모드에서는 파일을 업로드할 수 없습니다"
: "위의 영역에 파일을 업로드하세요"}
</p>
</div>
)}
</div>
</div>}
</div>
</div>
</DialogContent>
</Dialog>
{/* 파일 뷰어 모달 */}
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={config.allowDownload !== false ? onFileDownload : undefined}
onDelete={!isDesignMode && config.allowDelete !== false ? onFileDelete : undefined}
/>
</>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,287 +0,0 @@
"use client";
import React, { useMemo, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { FileUploadConfig } from "./types";
import { V2FileUploadDefaultConfig } from "./config";
export interface FileUploadConfigPanelProps {
config: FileUploadConfig;
onChange: (config: Partial<FileUploadConfig>) => void;
screenTableName?: string;
}
/**
* V2 FileUpload
* UI
*/
export const FileUploadConfigPanel: React.FC<FileUploadConfigPanelProps> = ({
config: propConfig,
onChange,
screenTableName,
}) => {
// config 안전하게 초기화 (useMemo)
const config = useMemo(() => ({
...V2FileUploadDefaultConfig,
...propConfig,
}), [propConfig]);
// 핸들러
const handleChange = useCallback(<K extends keyof FileUploadConfig>(
key: K,
value: FileUploadConfig[K]
) => {
onChange({ [key]: value });
}, [onChange]);
// 파일 크기를 MB 단위로 변환
const maxSizeMB = useMemo(() => {
return (config.maxSize || 10 * 1024 * 1024) / (1024 * 1024);
}, [config.maxSize]);
const handleMaxSizeChange = useCallback((value: string) => {
const mb = parseFloat(value) || 10;
handleChange("maxSize", mb * 1024 * 1024);
}, [handleChange]);
return (
<div className="space-y-4 p-4">
<div className="text-sm font-medium text-muted-foreground">
V2
</div>
<Separator />
{/* 기본 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs"></Label>
<Input
id="placeholder"
value={config.placeholder || ""}
onChange={(e) => handleChange("placeholder", e.target.value)}
placeholder="파일을 선택하세요"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="accept" className="text-xs"> </Label>
<Select
value={config.accept || "*/*"}
onValueChange={(value) => handleChange("accept", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="파일 형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="*/*"> </SelectItem>
<SelectItem value="image/*"></SelectItem>
<SelectItem value=".pdf,.doc,.docx,.xls,.xlsx"></SelectItem>
<SelectItem value="image/*,.pdf"> + PDF</SelectItem>
<SelectItem value=".zip,.rar,.7z"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label htmlFor="maxSize" className="text-xs"> (MB)</Label>
<Input
id="maxSize"
type="number"
min={1}
max={100}
value={maxSizeMB}
onChange={(e) => handleMaxSizeChange(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxFiles" className="text-xs"> </Label>
<Input
id="maxFiles"
type="number"
min={1}
max={50}
value={config.maxFiles || 10}
onChange={(e) => handleChange("maxFiles", parseInt(e.target.value) || 10)}
className="h-8 text-xs"
/>
</div>
</div>
</div>
<Separator />
{/* 동작 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
checked={config.multiple !== false}
onCheckedChange={(checked) => handleChange("multiple", checked as boolean)}
/>
<Label htmlFor="multiple" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="allowDelete"
checked={config.allowDelete !== false}
onCheckedChange={(checked) => handleChange("allowDelete", checked as boolean)}
/>
<Label htmlFor="allowDelete" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="allowDownload"
checked={config.allowDownload !== false}
onCheckedChange={(checked) => handleChange("allowDownload", checked as boolean)}
/>
<Label htmlFor="allowDownload" className="text-xs"> </Label>
</div>
</div>
<Separator />
{/* 표시 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="showPreview"
checked={config.showPreview !== false}
onCheckedChange={(checked) => handleChange("showPreview", checked as boolean)}
/>
<Label htmlFor="showPreview" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showFileList"
checked={config.showFileList !== false}
onCheckedChange={(checked) => handleChange("showFileList", checked as boolean)}
/>
<Label htmlFor="showFileList" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showFileSize"
checked={config.showFileSize !== false}
onCheckedChange={(checked) => handleChange("showFileSize", checked as boolean)}
/>
<Label htmlFor="showFileSize" className="text-xs"> </Label>
</div>
</div>
<Separator />
{/* 상태 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked as boolean)}
/>
<Label htmlFor="required" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked as boolean)}
/>
<Label htmlFor="readonly" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked as boolean)}
/>
<Label htmlFor="disabled" className="text-xs"></Label>
</div>
</div>
<Separator />
{/* 스타일 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="space-y-2">
<Label htmlFor="variant" className="text-xs"> </Label>
<Select
value={config.variant || "default"}
onValueChange={(value) => handleChange("variant", value as "default" | "outlined" | "filled")}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="스타일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
<SelectItem value="outlined"></SelectItem>
<SelectItem value="filled"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="size" className="text-xs"></Label>
<Select
value={config.size || "md"}
onValueChange={(value) => handleChange("size", value as "sm" | "md" | "lg")}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"></SelectItem>
<SelectItem value="md"></SelectItem>
<SelectItem value="lg"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 도움말 */}
<div className="space-y-2">
<Label htmlFor="helperText" className="text-xs"></Label>
<Input
id="helperText"
value={config.helperText || ""}
onChange={(e) => handleChange("helperText", e.target.value)}
placeholder="파일 업로드에 대한 안내 문구"
className="h-8 text-xs"
/>
</div>
</div>
);
};
@@ -1,545 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileInfo } from "./types";
import { Download, X, AlertTriangle, FileText, Trash2, ExternalLink } from "lucide-react";
import { formatFileSize } from "@/lib/utils";
import { API_BASE_URL } from "@/lib/api/client";
// Office 문서 렌더링을 위한 CDN 라이브러리 로드
const loadOfficeLibrariesFromCDN = async () => {
if (typeof window === "undefined") return { XLSX: null, mammoth: null };
try {
// XLSX 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).XLSX) {
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// mammoth 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).mammoth) {
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
return {
XLSX: (window as any).XLSX,
mammoth: (window as any).mammoth,
};
} catch (error) {
console.error("Office 라이브러리 CDN 로드 실패:", error);
return { XLSX: null, mammoth: null };
}
};
interface FileViewerModalProps {
file: FileInfo | null;
isOpen: boolean;
onClose: () => void;
onDownload?: (file: FileInfo) => void;
onDelete?: (file: FileInfo) => void;
}
/**
*
*/
export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen, onClose, onDownload, onDelete }) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [renderedContent, setRenderedContent] = useState<string | null>(null);
// Office 문서를 CDN 라이브러리로 렌더링하는 함수
const renderOfficeDocument = async (blob: Blob, fileExt: string, fileName: string) => {
try {
setIsLoading(true);
// CDN에서 라이브러리 로드
const { XLSX, mammoth } = await loadOfficeLibrariesFromCDN();
if (fileExt === "docx" && mammoth) {
// Word 문서 렌더링
const arrayBuffer = await blob.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer });
const htmlContent = `
<div>
<h4 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">📄 ${fileName}</h4>
<div class="word-content" style="max-height: 500px; overflow-y: auto; padding: 20px; background: white; border: 1px solid #ddd; border-radius: 5px; line-height: 1.6; font-family: 'Times New Roman', serif;">
${result.value || "내용을 읽을 수 없습니다."}
</div>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (["xlsx", "xls"].includes(fileExt) && XLSX) {
// Excel 문서 렌더링
const arrayBuffer = await blob.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: "array" });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(worksheet, {
table: { className: "excel-table" },
});
const htmlContent = `
<div>
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4>
<p style="margin: 0 0 15px 0; color: #666; font-size: 14px;">시트: ${sheetName}</p>
<div style="max-height: 400px; overflow: auto; border: 1px solid #ddd; border-radius: 5px;">
<style>
.excel-table { border-collapse: collapse; width: 100%; }
.excel-table td, .excel-table th { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 12px; }
.excel-table th { background-color: #f5f5f5; font-weight: bold; }
</style>
${html}
</div>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (fileExt === "doc") {
// .doc 파일은 .docx로 변환 안내
const htmlContent = `
<div style="text-align: center; padding: 40px;">
<h3 style="color: #333; margin-bottom: 15px;">📄 ${fileName}</h3>
<p style="color: #666; margin-bottom: 10px;">.doc .docx로 .</p>
<p style="color: #666; font-size: 14px;">(.docx )</p>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (["ppt", "pptx"].includes(fileExt)) {
// PowerPoint는 미리보기 불가 안내
const htmlContent = `
<div style="text-align: center; padding: 40px;">
<h3 style="color: #333; margin-bottom: 15px;">📑 ${fileName}</h3>
<p style="color: #666; margin-bottom: 10px;">PowerPoint .</p>
<p style="color: #666; font-size: 14px;"> .</p>
</div>
`;
setRenderedContent(htmlContent);
return true;
}
return false; // 지원하지 않는 형식
} catch (error) {
console.error("Office 문서 렌더링 오류:", error);
const htmlContent = `
<div style="color: red; text-align: center; padding: 20px;">
Office .<br>
.
</div>
`;
setRenderedContent(htmlContent);
return true; // 오류 메시지라도 표시
} finally {
setIsLoading(false);
}
};
// 파일이 변경될 때마다 미리보기 URL 생성
useEffect(() => {
if (!file || !isOpen) {
setPreviewUrl(null);
setPreviewError(null);
setRenderedContent(null);
return;
}
setIsLoading(true);
setPreviewError(null);
// 로컬 파일인 경우
if (file._file) {
const url = URL.createObjectURL(file._file);
setPreviewUrl(url);
setIsLoading(false);
return () => URL.revokeObjectURL(url);
}
let cleanup: (() => void) | undefined;
// 서버 파일인 경우 - 미리보기 API 호출
const generatePreviewUrl = async () => {
try {
const fileExt = file.fileExt.toLowerCase();
// 미리보기 지원 파일 타입 정의
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
const documentExtensions = [
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"rtf",
"odt",
"ods",
"odp",
"hwp",
"hwpx",
"hwpml",
"hcdt",
"hpt",
"pages",
"numbers",
"keynote",
];
const textExtensions = ["txt", "md", "json", "xml", "csv"];
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
const supportedExtensions = [...imageExtensions, ...documentExtensions, ...textExtensions, ...mediaExtensions];
if (supportedExtensions.includes(fileExt)) {
// 이미지나 PDF는 인증된 요청으로 Blob 생성
if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
try {
// 인증된 요청으로 파일 데이터 가져오기
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
},
});
if (response.ok) {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
// 컴포넌트 언마운트 시 URL 정리를 위해 cleanup 함수 저장
cleanup = () => URL.revokeObjectURL(blobUrl);
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error("파일 미리보기 로드 실패:", error);
setPreviewError("파일을 불러올 수 없습니다. 권한을 확인해주세요.");
}
} else if (documentExtensions.includes(fileExt)) {
// Office 문서는 OnlyOffice 또는 안정적인 뷰어 사용
try {
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
},
});
if (response.ok) {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Office 문서를 위한 특별한 처리 - CDN 라이브러리 사용
if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(fileExt)) {
// CDN 라이브러리로 클라이언트 사이드 렌더링 시도
try {
const renderSuccess = await renderOfficeDocument(blob, fileExt, file.realFileName);
if (!renderSuccess) {
// 렌더링 실패 시 Blob URL 사용
setPreviewUrl(blobUrl);
}
} catch (error) {
console.error("Office 문서 렌더링 중 오류:", error);
// 오류 발생 시 Blob URL 사용
setPreviewUrl(blobUrl);
}
} else {
// 기타 문서는 직접 Blob URL 사용
setPreviewUrl(blobUrl);
}
return () => URL.revokeObjectURL(blobUrl); // Cleanup function
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error("Office 문서 로드 실패:", error);
// 오류 발생 시 다운로드 옵션 제공
setPreviewError(`${fileExt.toUpperCase()} 문서를 미리보기할 수 없습니다. 다운로드하여 확인해주세요.`);
}
} else {
// 기타 파일은 다운로드 URL 사용
// 주의: 프로덕션 URL이 https://api.invyone.com/api 이므로
// 끝의 /api만 제거해야 호스트명이 깨지지 않음
const url = `${API_BASE_URL.replace(/\/api$/, "")}/api/files/download/${file.objid}`;
setPreviewUrl(url);
}
} else {
// 지원하지 않는 파일 타입
setPreviewError(`${file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.`);
}
} catch (error) {
console.error("미리보기 URL 생성 오류:", error);
setPreviewError("미리보기를 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
generatePreviewUrl();
// cleanup 함수 반환
return () => {
if (cleanup) {
cleanup();
}
};
}, [file, isOpen]);
if (!file) return null;
// 파일 타입별 미리보기 컴포넌트
const renderPreview = () => {
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
);
}
if (previewError) {
return (
<div className="flex h-96 flex-col items-center justify-center">
<AlertTriangle className="mb-4 h-16 w-16 text-amber-500" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="text-center text-sm">{previewError}</p>
<Button variant="outline" onClick={() => onDownload?.(file)} className="mt-4">
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
);
}
const fileExt = file.fileExt.toLowerCase();
// 이미지 파일
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
return (
<div className="flex max-h-96 items-center justify-center overflow-hidden">
<img
src={previewUrl || ""}
alt={file.realFileName}
className="max-h-full max-w-full rounded-lg object-contain shadow-lg"
onError={(e) => {
console.error("이미지 로드 오류:", previewUrl, e);
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
}}
onLoad={() => {
console.log("이미지 로드 성공:", previewUrl);
}}
/>
</div>
);
}
// 텍스트 파일
if (["txt", "md", "json", "xml", "csv"].includes(fileExt)) {
return (
<div className="h-96 overflow-auto">
<iframe
src={previewUrl || ""}
className="h-full w-full rounded-lg border"
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
/>
</div>
);
}
// PDF 파일 - 브라우저 기본 뷰어 사용
if (fileExt === "pdf") {
return (
<div className="h-[600px] overflow-auto rounded-lg border bg-muted">
<object
data={previewUrl || ""}
type="application/pdf"
className="h-full w-full rounded-lg"
title="PDF Viewer"
>
<iframe src={previewUrl || ""} className="h-full w-full rounded-lg" title="PDF Viewer Fallback">
<div className="flex h-full flex-col items-center justify-center p-8">
<FileText className="mb-4 h-16 w-16 text-muted-foreground/70" />
<p className="mb-2 text-lg font-medium">PDF를 </p>
<p className="mb-4 text-center text-sm text-muted-foreground">
PDF . .
</p>
<Button variant="outline" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
PDF
</Button>
</div>
</iframe>
</object>
</div>
);
}
// Office 문서 - 모든 Office 문서는 다운로드 권장
if (
[
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"hwp",
"hwpx",
"hwpml",
"hcdt",
"hpt",
"pages",
"numbers",
"keynote",
].includes(fileExt)
) {
// Office 문서 안내 메시지 표시
return (
<div className="relative flex h-96 flex-col items-center justify-center overflow-auto rounded-lg border bg-gradient-to-br from-primary/5 to-indigo-50 p-8">
<FileText className="mb-6 h-20 w-20 text-primary" />
<h3 className="mb-2 text-xl font-semibold text-foreground">Office </h3>
<p className="mb-6 max-w-md text-center text-sm text-muted-foreground">
{fileExt === "docx" || fileExt === "doc"
? "Word 문서"
: fileExt === "xlsx" || fileExt === "xls"
? "Excel 문서"
: fileExt === "pptx" || fileExt === "ppt"
? "PowerPoint 문서"
: "Office 문서"}
.
<br />
.
</p>
<div className="flex gap-3">
<Button onClick={() => onDownload?.(file)} size="lg" className="shadow-md">
<Download className="mr-2 h-5 w-5" />
</Button>
</div>
</div>
);
}
// 비디오 파일
if (["mp4", "webm", "ogg"].includes(fileExt)) {
return (
<div className="flex items-center justify-center">
<video controls className="max-h-96 w-full" onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}>
<source src={previewUrl || ""} type={`video/${fileExt}`} />
</video>
</div>
);
}
// 오디오 파일
if (["mp3", "wav", "ogg"].includes(fileExt)) {
return (
<div className="flex h-96 flex-col items-center justify-center">
<div className="mb-6 flex h-32 w-32 items-center justify-center rounded-full bg-muted">
<svg className="h-16 w-16 text-muted-foreground/70" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
<audio controls className="w-full max-w-md" onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}>
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
</audio>
</div>
);
}
// 기타 파일 타입
return (
<div className="flex h-96 flex-col items-center justify-center">
<FileText className="mb-4 h-16 w-16 text-muted-foreground/70" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="mb-4 text-center text-sm">{file.fileExt.toUpperCase()} .</p>
<Button variant="outline" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden [&>button]:hidden">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<DialogTitle className="truncate text-lg font-semibold">{file.realFileName}</DialogTitle>
<Badge variant="secondary" className="text-xs">
{file.fileExt.toUpperCase()}
</Badge>
</div>
</div>
<DialogDescription>
: {formatFileSize(file.fileSize || file.size || 0)} | : {file.fileExt.toUpperCase()}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">{renderPreview()}</div>
{/* 파일 정보 및 액션 버튼 */}
<div className="mt-2 flex items-center space-x-4 text-sm text-muted-foreground">
<span>: {formatFileSize(file.fileSize || file.size || 0)}</span>
{(file.uploadedAt || file.regdate) && (
<span>: {new Date(file.uploadedAt || file.regdate || "").toLocaleString()}</span>
)}
</div>
<div className="flex justify-end space-x-2 border-t pt-4">
<Button variant="outline" size="sm" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
</Button>
{onDelete && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => onDelete(file)}
>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
)}
<Button variant="outline" size="sm" onClick={onClose}>
<X className="mr-2 h-4 w-4" />
</Button>
</div>
</DialogContent>
</Dialog>
);
};
@@ -1,56 +0,0 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2FileUploadDefinition } from "./index";
import { FileUploadComponent } from "./FileUploadComponent";
/**
* V2 FileUpload
*
*/
export class V2FileUploadRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2FileUploadDefinition;
render(): React.ReactElement {
return <FileUploadComponent {...(this.props as any)} renderer={this} />;
}
/**
*
*/
// file 타입 특화 속성 처리
protected getFileUploadProps() {
const baseProps = this.getWebTypeProps();
// file 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 file 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
V2FileUploadRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
V2FileUploadRenderer.enableHotReload();
}
@@ -1,62 +0,0 @@
"use client";
import { FileUploadConfig } from "./types";
/**
* V2 FileUpload
*/
export const V2FileUploadDefaultConfig: FileUploadConfig = {
placeholder: "파일을 선택하세요",
multiple: true,
accept: "*/*",
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 10,
// 공통 기본값
disabled: false,
required: false,
readonly: false,
variant: "default",
size: "md",
// V2 추가 설정 기본값
showPreview: true,
showFileList: true,
showFileSize: true,
allowDelete: true,
allowDownload: true,
};
/**
* V2 FileUpload
*
*/
export const V2FileUploadConfigSchema = {
placeholder: { type: "string", default: "파일을 선택하세요" },
multiple: { type: "boolean", default: true },
accept: { type: "string", default: "*/*" },
maxSize: { type: "number", default: 10 * 1024 * 1024 },
maxFiles: { type: "number", default: 10 },
// 공통 스키마
disabled: { type: "boolean", default: false },
required: { type: "boolean", default: false },
readonly: { type: "boolean", default: false },
variant: {
type: "enum",
values: ["default", "outlined", "filled"],
default: "default"
},
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md"
},
// V2 추가 설정 스키마
showPreview: { type: "boolean", default: true },
showFileList: { type: "boolean", default: true },
showFileSize: { type: "boolean", default: true },
allowDelete: { type: "boolean", default: true },
allowDownload: { type: "boolean", default: true },
};
@@ -1,47 +0,0 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { FileUploadComponent } from "./FileUploadComponent";
import { V2FileUploadConfigPanel } from "@/components/v2/config-panels/V2FileUploadConfigPanel";
import { FileUploadConfig } from "./types";
/**
* V2 FileUpload
* V2
*/
export const V2FileUploadDefinition = createComponentDefinition({
id: "v2-file-upload",
hidden: true, // Phase E: 통합 컴포넌트로 대체됨
name: "파일 업로드",
name_eng: "V2 FileUpload Component",
description: "V2 파일 업로드를 위한 파일 선택 컴포넌트",
category: ComponentCategory.INPUT,
web_type: "file",
component: FileUploadComponent,
default_config: {
placeholder: "파일을 선택하세요",
multiple: true,
accept: "*/*",
maxSize: 10 * 1024 * 1024, // 10MB
},
default_size: { width: 350, height: 240 },
config_panel: V2FileUploadConfigPanel,
icon: "Upload",
tags: ["file", "upload", "attachment", "v2"],
version: "2.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/v2-file-upload",
});
// 타입 내보내기
export type { FileUploadConfig, FileInfo, FileUploadProps, FileUploadStatus, FileUploadResponse } from "./types";
// 컴포넌트 내보내기
export { FileUploadComponent } from "./FileUploadComponent";
export { V2FileUploadRenderer } from "./V2FileUploadRenderer";
// 기본 export
export default V2FileUploadDefinition;
@@ -1,114 +0,0 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* (AttachedFileInfo와 )
*/
export interface FileInfo {
// AttachedFileInfo 기본 속성들
objid: string;
savedFileName: string;
realFileName: string;
fileSize: number;
fileExt: string;
filePath: string;
docType?: string;
docTypeName?: string;
targetObjid: string;
parentTargetObjid?: string;
companyCode?: string;
writer?: string;
regdate?: string;
status?: string;
// 추가 호환성 속성들
path?: string; // filePath와 동일
name?: string; // realFileName과 동일
id?: string; // objid와 동일
size?: number; // fileSize와 동일
type?: string; // docType과 동일
uploadedAt?: string; // regdate와 동일
_file?: File; // 로컬 파일 객체 (업로드 전)
// 대표 이미지 설정
isRepresentative?: boolean; // 대표 이미지로 설정 여부
}
/**
* V2 FileUpload
*/
export interface FileUploadConfig extends ComponentConfig {
// file 관련 설정
placeholder?: string;
multiple?: boolean;
accept?: string;
maxSize?: number; // bytes
maxFiles?: number; // 최대 파일 수
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
helperText?: string;
// 스타일 관련
variant?: "default" | "outlined" | "filled";
size?: "sm" | "md" | "lg";
// V2 추가 설정
showPreview?: boolean; // 미리보기 표시 여부
showFileList?: boolean; // 파일 목록 표시 여부
showFileSize?: boolean; // 파일 크기 표시 여부
allowDelete?: boolean; // 삭제 허용 여부
allowDownload?: boolean; // 다운로드 허용 여부
// 이벤트 관련
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
onFileUpload?: (files: FileInfo[]) => void;
onFileDelete?: (fileId: string) => void;
onFileDownload?: (file: FileInfo) => void;
}
/**
* V2 FileUpload Props
*/
export interface FileUploadProps {
id?: string;
name?: string;
value?: any;
config?: FileUploadConfig;
className?: string;
style?: React.CSSProperties;
// 파일 관련
uploadedFiles?: FileInfo[];
// 이벤트 핸들러
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
onFileUpload?: (files: FileInfo[]) => void;
onFileDelete?: (fileId: string) => void;
onFileDownload?: (file: FileInfo) => void;
}
/**
*
*/
export type FileUploadStatus = "idle" | "uploading" | "success" | "error";
/**
*
*/
export interface FileUploadResponse {
success: boolean;
data?: FileInfo[];
files?: FileInfo[];
message?: string;
error?: string;
}
@@ -1,134 +0,0 @@
"use client";
import React, { useEffect, useRef } from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2InputDefinition } from "./index";
import { V2Input } from "@/components/v2/V2Input";
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
/**
* dataBinding이 v2-input을 wrapper
* v2-table-list의 window CustomEvent로
* formData에
*/
function DataBindingWrapper({
dataBinding,
columnName,
onFormDataChange,
children,
}: {
dataBinding: { sourceComponentId: string; sourceColumn: string };
columnName: string;
onFormDataChange?: (field: string, value: any) => void;
children: React.ReactNode;
}) {
const lastBoundValueRef = useRef<any>(null);
const onFormDataChangeRef = useRef(onFormDataChange);
onFormDataChangeRef.current = onFormDataChange;
useEffect(() => {
if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return;
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail || detail.source !== dataBinding.sourceComponentId) return;
const selectedRow = detail.data?.[0];
const value = selectedRow?.[dataBinding.sourceColumn] ?? "";
if (value !== lastBoundValueRef.current) {
lastBoundValueRef.current = value;
onFormDataChangeRef.current?.(columnName, value);
}
};
window.addEventListener("v2-table-selection", handler);
return () => window.removeEventListener("v2-table-selection", handler);
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName]);
return <>{children}</>;
}
/**
* V2Input
*
*/
export class V2InputRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2InputDefinition;
render(): React.ReactElement {
const { component, form_data, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
const config = component.componentConfig || component.config || {};
const columnName = component.column_name;
const tableName = component.table_name || this.props.tableName;
const currentValue = form_data?.[columnName] ?? component.value ?? "";
const handleChange = (value: any) => {
if (isInteractive && onFormDataChange && columnName) {
onFormDataChange(columnName, value);
}
};
const style = component.style || {};
const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay;
const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined;
const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding;
const inputElement = (
<V2Input
id={component.id}
value={currentValue}
onChange={handleChange}
onFormDataChange={onFormDataChange}
config={{
type: config.inputType || config.webType || "text",
input_type: config.inputType || config.webType || "text",
placeholder: config.placeholder,
format: config.format,
min: config.min,
max: config.max,
step: config.step,
rows: config.rows,
autoGeneration: config.autoGeneration || component.auto_generation,
}}
style={component.style}
size={component.size}
formData={form_data}
columnName={columnName}
tableName={tableName}
autoGeneration={config.autoGeneration || component.auto_generation}
originalData={(this.props as any).originalData}
{...restProps}
label={effectiveLabel}
required={component.required || isColumnRequiredByMeta(tableName, columnName)}
readonly={config.readonly || component.readonly || !!dataBinding?.sourceComponentId}
disabled={config.disabled || component.disabled}
/>
);
// dataBinding이 있으면 wrapper로 감싸서 이벤트 구독
if (dataBinding?.sourceComponentId && dataBinding?.sourceColumn) {
return (
<DataBindingWrapper
dataBinding={dataBinding}
columnName={columnName}
onFormDataChange={onFormDataChange}
>
{inputElement}
</DataBindingWrapper>
);
}
return inputElement;
}
}
// 자동 등록 실행
V2InputRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
V2InputRenderer.enableHotReload();
}
@@ -1,44 +0,0 @@
/**
* V2Input
*
* , ,
*/
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
import { V2Input } from "@/components/v2/V2Input";
import { withContainerQuery } from "../../hoc/withContainerQuery";
export const V2InputDefinition = createComponentDefinition({
id: "v2-input",
hidden: true, // Phase E: 통합 컴포넌트로 대체됨
name: "V2 입력",
description: "텍스트, 숫자, 비밀번호 등 다양한 입력 타입 지원",
category: ComponentCategory.INPUT,
web_type: "text",
version: "2.0.0",
component: withContainerQuery(V2Input, "v2-input"),
default_size: { width: 200, height: 36 },
// 아이콘
icon: "TextCursorInput",
// 태그
tags: ["input", "text", "number", "v2"],
// 설정 패널
config_panel: InvFieldConfigPanel,
// ─── INVYONE DataPort 선언 ───
dataPorts: {
inputs: [{ name: "value", type: "value" }],
outputs: [
{ name: "value", type: "value" },
{ name: "changed", type: "value" },
],
},
});
export default V2InputDefinition;
@@ -1,136 +0,0 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2MediaDefinition } from "./index";
import FileUploadComponent from "../file-upload/FileUploadComponent";
/**
* V2Media
* FileUploadComponent를
*
*/
export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2MediaDefinition;
render(): React.ReactElement {
const {
component,
formData,
onFormDataChange,
isDesignMode,
isSelected,
isInteractive,
onUpdate,
...restProps
} = this.props;
// 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {};
const columnName = component.column_name;
const tableName = component.table_name || this.props.tableName;
// 🔍 디버깅: 컴포넌트 정보 로깅
console.log("📸 [V2MediaRenderer] 컴포넌트 정보:", {
componentId: component.id,
columnName: columnName,
tableName: tableName,
formDataId: formData?.id,
formDataTableName: formData?.table_name,
});
// V1 file-upload에서 사용하는 형태로 설정 매핑
const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType);
// maxSize: MB → bytes 변환
const maxSizeBytes = config.maxSize
? (config.maxSize > 1000 ? config.maxSize : config.maxSize * 1024 * 1024)
: 10 * 1024 * 1024; // 기본 10MB
// 레거시 컴포넌트 설정 형태로 변환
const legacyComponentConfig = {
maxFileCount: config.multiple ? 10 : 1,
maxFileSize: maxSizeBytes,
accept: config.accept || this.getDefaultAccept(mediaType),
docType: config.docType || "DOCUMENT",
docTypeName: config.docTypeName || "일반 문서",
showFileList: config.showFileList ?? true,
dragDrop: config.dragDrop ?? true,
};
// 레거시 컴포넌트 형태로 변환
const legacyComponent = {
...component,
id: component.id,
column_name: columnName,
table_name: tableName,
componentConfig: legacyComponentConfig,
};
// onFormDataChange 래퍼: FileUploadComponent는 (fieldName, value) 형태로 호출함
const handleFormDataChange = (fieldName: string, value: any) => {
if (onFormDataChange) {
// 메타 데이터(__로 시작하는 키)는 건너뛰기
if (!fieldName.startsWith("__")) {
console.log("📸 [V2MediaRenderer] formData 업데이트:", { fieldName, value });
onFormDataChange(fieldName, value);
}
}
};
return (
<FileUploadComponent
component={legacyComponent}
componentConfig={legacyComponentConfig}
componentStyle={component.style || {}}
className=""
isInteractive={isInteractive ?? true}
isDesignMode={isDesignMode ?? false}
formData={formData || {}}
onFormDataChange={handleFormDataChange as any}
onUpdate={onUpdate}
/>
);
}
/**
* webType에서
*/
private getMediaTypeFromWebType(webType?: string): "file" | "image" | "video" | "audio" {
switch (webType) {
case "image":
return "image";
case "video":
return "video";
case "audio":
return "audio";
case "file":
default:
return "file";
}
}
/**
* accept
*/
private getDefaultAccept(mediaType: string): string {
switch (mediaType) {
case "image":
return "image/*";
case "video":
return "video/*";
case "audio":
return "audio/*";
default:
return "*/*";
}
}
}
// 자동 등록 실행
V2MediaRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
V2MediaRenderer.enableHotReload();
}
@@ -1,33 +0,0 @@
/**
* V2Media
*
* , , ,
*/
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { V2MediaConfigPanel } from "@/components/v2/config-panels/V2MediaConfigPanel";
import { V2Media } from "@/components/v2/V2Media";
export const V2MediaDefinition = createComponentDefinition({
id: "v2-media",
hidden: true, // Phase E: 통합 컴포넌트로 대체됨
name: "V2 미디어",
description: "파일, 이미지, 비디오, 오디오 등 다양한 미디어 타입 지원",
category: ComponentCategory.INPUT,
web_type: "file",
version: "2.0.0",
component: V2Media,
default_size: { width: 300, height: 200 },
// 아이콘
icon: "Upload",
// 태그
tags: ["media", "file", "image", "upload", "v2"],
// 설정 패널
config_panel: V2MediaConfigPanel,
});
export default V2MediaDefinition;
@@ -1,156 +0,0 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2SelectDefinition } from "./index";
import { V2Select } from "@/components/v2/V2Select";
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
/**
* V2Select
*
*/
export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2SelectDefinition;
render(): React.ReactElement {
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, allComponents, ...restProps } = this.props as any;
// 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {};
const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName;
// 🔧 카테고리 타입 감지 (inputType 또는 webType이 category인 경우)
const inputType = component.componentConfig?.inputType || component.inputType;
const webType = component.componentConfig?.webType || component.webType;
const isCategoryType = inputType === "category" || webType === "category";
// formData에서 현재 값 가져오기 (기본값 지원)
const defaultValue = config.defaultValue || "";
// 🔧 tagbox, check, tag, swap 모드는 본질적으로 다중 선택
const multiSelectModes = ["tagbox", "check", "checkbox", "tag", "swap"];
const isMultiple = config.multiple || multiSelectModes.includes(config.mode);
let currentValue = formData?.[columnName] ?? component.value ?? "";
// 🔧 다중 선택 시 값 정규화 (잘못된 형식 필터링)
if (isMultiple) {
// 헬퍼: 유효한 값인지 체크 (중괄호, 따옴표, 백슬래시 없어야 함)
// 숫자도 유효한 값으로 처리
const isValidValue = (v: any): boolean => {
// 숫자면 유효
if (typeof v === "number" && !isNaN(v)) return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
};
if (typeof currentValue === "string" && currentValue) {
// 🔧 PostgreSQL 배열 형식 또는 중첩된 잘못된 형식 감지
if (currentValue.startsWith("{") || currentValue.includes('{"') || currentValue.includes('\\"')) {
currentValue = [];
} else if (currentValue.includes(",")) {
// 쉼표 구분 문자열 파싱 후 유효한 값만 필터링
currentValue = currentValue
.split(",")
.map((v) => v.trim())
.filter(isValidValue);
} else if (isValidValue(currentValue)) {
currentValue = [currentValue];
} else {
currentValue = [];
}
} else if (Array.isArray(currentValue)) {
// 🔧 배열일 때도 잘못된 값 필터링 + 숫자→문자열 변환!
const filtered = currentValue.map((v) => (typeof v === "number" ? String(v) : v)).filter(isValidValue);
currentValue = filtered;
} else {
currentValue = [];
}
}
// 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용
// 단, formData에 해당 키가 이미 존재하면(사용자가 명시적으로 초기화한 경우) 기본값을 재적용하지 않음
const hasKeyInFormData = formData !== undefined && formData !== null && columnName in (formData || {});
if (
(currentValue === "" || currentValue === undefined || currentValue === null) &&
defaultValue &&
isInteractive &&
onFormDataChange &&
columnName &&
!hasKeyInFormData // formData에 키 자체가 없을 때만 기본값 적용 (초기 렌더링)
) {
setTimeout(() => {
onFormDataChange(columnName, defaultValue);
}, 0);
currentValue = defaultValue;
}
// 값 변경 핸들러 (배열 → 쉼표 구분 문자열로 변환하여 저장)
const handleChange = (value: any) => {
if (isInteractive && onFormDataChange && columnName) {
// 🔧 배열이면 무조건 쉼표 구분 문자열로 변환 (PostgreSQL 배열 형식 방지)
if (Array.isArray(value)) {
const stringValue = value.map((v) => (typeof v === "number" ? String(v) : v)).join(",");
onFormDataChange(columnName, stringValue);
} else {
onFormDataChange(columnName, value);
}
}
};
// 🔧 DynamicComponentRenderer에서 전달한 style/size를 우선 사용 (height 포함)
// restProps.style에 mergedStyle(height 변환됨)이 있고, restProps.size에도 size가 있음
const effectiveStyle = restProps.style || component.style;
const effectiveSize = restProps.size || component.size;
// 디버깅 필요시 주석 해제
// console.log("🔍 [V2SelectRenderer]", { componentId: component.id, effectiveStyle, effectiveSize });
const { style: _style, size: _size, allComponents: _allComp, ...restPropsClean } = restProps as any;
return (
<V2Select
id={component.id}
value={currentValue}
onChange={handleChange}
onFormDataChange={isInteractive ? onFormDataChange : undefined}
allComponents={allComponents}
config={{
mode: config.mode || "dropdown",
source: isCategoryType ? "category" : (config.source || "distinct"),
multiple: config.multiple || false,
searchable: config.searchable ?? true,
placeholder: config.placeholder || "선택하세요",
options: config.options || [],
codeGroup: config.codeGroup,
entityTable: config.entityTable,
entityLabelColumn: config.entityLabelColumn,
entityValueColumn: config.entityValueColumn,
categoryTable: config.categoryTable || (isCategoryType ? tableName : undefined),
categoryColumn: config.categoryColumn || (isCategoryType ? columnName : undefined),
}}
tableName={tableName}
columnName={columnName}
formData={formData}
isDesignMode={isDesignMode}
{...restPropsClean}
style={effectiveStyle}
size={effectiveSize}
label={component.label}
required={component.required || isColumnRequiredByMeta(tableName, columnName)}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
/>
);
}
}
// 자동 등록 실행
V2SelectRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
V2SelectRenderer.enableHotReload();
}
@@ -1,44 +0,0 @@
/**
* V2Select
*
* , , ,
*/
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
import { V2Select } from "@/components/v2/V2Select";
import { withContainerQuery } from "../../hoc/withContainerQuery";
export const V2SelectDefinition = createComponentDefinition({
id: "v2-select",
hidden: true, // Phase E: 통합 컴포넌트로 대체됨
name: "V2 선택",
description: "드롭다운, 콤보박스, 라디오, 체크박스 등 다양한 선택 모드 지원",
category: ComponentCategory.INPUT,
web_type: "select",
version: "2.0.0",
component: withContainerQuery(V2Select, "v2-select"),
default_config: {
mode: "dropdown",
source: "distinct",
multiple: false,
searchable: true,
placeholder: "선택하세요",
required: false,
readonly: false,
disabled: false,
},
default_size: { width: 200, height: 40 },
// 아이콘
icon: "ChevronDown",
// 태그
tags: ["select", "dropdown", "combobox", "v2"],
// 설정 패널
config_panel: InvFieldConfigPanel,
});
export default V2SelectDefinition;
@@ -28,31 +28,8 @@
}
}
/* ── v2-input — narrow 에서 라벨 위로 (flex-direction column) ── */
@container v2-input (max-width: 400px) {
.v2-container-query-root {
display: flex;
flex-direction: column;
}
.v2-container-query-root label {
display: block;
margin-bottom: 0.2rem;
text-align: left;
}
}
/* ── v2-select — narrow 에서 라벨 위로 ── */
@container v2-select (max-width: 400px) {
.v2-container-query-root {
display: flex;
flex-direction: column;
}
.v2-container-query-root label {
display: block;
margin-bottom: 0.2rem;
text-align: left;
}
}
/* (V2 입력/선택 container query Phase D.2 에서 제거. canonical `input`
자체 라벨 처리로 별도 container query 불필요) */
/* ── v2-text-display — narrow 에서 font-size 1단계 축소 ── */
@container v2-text-display (max-width: 300px) {
+7 -44
View File
@@ -495,23 +495,8 @@ const v2V2RepeaterOverridesSchema = z
// V2 컴포넌트 overrides 스키마 정의
// ============================================
// v2-input
const v2InputOverridesSchema = z
.object({
inputType: z.string().default("text"),
format: z.string().default("none"),
placeholder: z.string().default(""),
})
.passthrough();
// v2-select
const v2SelectOverridesSchema = z
.object({
mode: z.string().default("dropdown"),
source: z.string().default("static"),
options: z.array(z.any()).default([]),
})
.passthrough();
// V2 입력/선택 폐기 (Phase D.2, 2026-05-12) — input canonical 로 흡수.
// override schema / default config 모두 제거. fallback schema (default any.passthrough) 사용.
// v2-list
const v2ListOverridesSchema = z
@@ -544,14 +529,7 @@ const v2GroupOverridesSchema = z
})
.passthrough();
// v2-media
const v2MediaOverridesSchema = z
.object({
mediaType: z.string().default("image"),
multiple: z.boolean().default(false),
preview: z.boolean().default(true),
})
.passthrough();
// v2-media — Phase D.5 폐기. canonical input 의 file 분기로 흡수.
// v2-biz
const v2BizOverridesSchema = z
@@ -657,12 +635,11 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
lineWidth: z.number().default(4),
}).passthrough(),
"v2-input": v2InputOverridesSchema,
"v2-select": v2SelectOverridesSchema,
// V2 입력/선택 폐기 (Phase D.2) — schema 미제공.
"v2-list": v2ListOverridesSchema,
"v2-layout": v2LayoutOverridesSchema,
"v2-group": v2GroupOverridesSchema,
"v2-media": v2MediaOverridesSchema,
// v2-media 폐기 (Phase D.5) — schema 미제공.
"v2-biz": v2BizOverridesSchema,
"v2-hierarchy": v2HierarchyOverridesSchema,
};
@@ -793,17 +770,7 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
allowCloseable: false,
persistSelection: false,
},
// V2 컴포넌트
"v2-input": {
inputType: "text",
format: "none",
placeholder: "",
},
"v2-select": {
mode: "dropdown",
source: "static",
options: [],
},
// V2 컴포넌트 (V2 입력/선택 폐기, Phase D.2)
"v2-list": {
viewMode: "table",
source: "static",
@@ -823,11 +790,7 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
collapsible: false,
defaultOpen: true,
},
"v2-media": {
mediaType: "image",
multiple: false,
preview: true,
},
// v2-media 폐기 (Phase D.5) — canonical input 의 file 분기로 흡수.
"v2-biz": {
bizType: "flow",
},
@@ -23,10 +23,9 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"container": () => import("@/lib/registry/components/container/InvContainerConfigPanel"),
// ========== V2 컴포넌트 ==========
"v2-input": () => import("@/components/v2/config-panels/InvFieldConfigPanel"),
"v2-select": () => import("@/components/v2/config-panels/InvFieldConfigPanel"),
// V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. alias / fallback / schema 미제공.
"v2-list": () => import("@/components/v2/config-panels/InvDataConfigPanel"),
"v2-media": () => import("@/components/v2/config-panels/V2MediaConfigPanel"),
// v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수, ConfigPanel 미제공.
"v2-biz": () => import("@/components/v2/config-panels/V2BizConfigPanel"),
"v2-group": () => import("@/components/v2/config-panels/V2GroupConfigPanel"),
"v2-hierarchy": () => import("@/components/v2/config-panels/V2HierarchyConfigPanel"),
@@ -42,7 +41,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"checkbox-basic": () => import("@/lib/registry/components/checkbox-basic/CheckboxBasicConfigPanel"),
"radio-basic": () => import("@/lib/registry/components/radio-basic/RadioBasicConfigPanel"),
"toggle-switch": () => import("@/lib/registry/components/toggle-switch/ToggleSwitchConfigPanel"),
"file-upload": () => import("@/lib/registry/components/file-upload/FileUploadConfigPanel"),
// file-upload — Phase D.5 폐기. canonical input 의 InvFieldConfigPanel 이 file 분기 처리.
"slider-basic": () => import("@/lib/registry/components/slider-basic/SliderBasicConfigPanel"),
"test-input": () => import("@/lib/registry/components/test-input/TestInputConfigPanel"),
@@ -55,11 +54,10 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"text-display": () => import("@/lib/registry/components/text-display/TextDisplayConfigPanel"),
// v2-text-display: hidden 호환 — InvLegacy 패널 사용
"v2-text-display": () => import("@/components/v2/config-panels/InvLegacyTextConfigPanel"),
"image-display": () => import("@/lib/registry/components/image-display/ImageDisplayConfigPanel"),
// image-display / image-widget — Phase D.5 폐기. canonical input 의 file 분기로 흡수.
"divider-line": () => import("@/lib/registry/components/divider-line/DividerLineConfigPanel"),
// v2-divider-line: hidden 호환 — InvLegacy 패널 사용
"v2-divider-line": () => import("@/components/v2/config-panels/InvLegacyDividerConfigPanel"),
"image-widget": () => import("@/lib/registry/components/image-widget/ImageWidgetConfigPanel"),
// ========== 레이아웃/컨테이너 ==========
"accordion-basic": () => import("@/lib/registry/components/accordion-basic/AccordionBasicConfigPanel"),
@@ -148,7 +146,6 @@ const CONFIG_PANEL_ALIAS: Record<string, string> = {
"text-display": "title",
"button-primary": "button",
"v2-table-search-widget": "search", "table-search-widget": "search",
"v2-input": "input", "v2-select": "input",
"text-input": "input", "number-input": "input", "date-input": "input",
"select-basic": "input", "checkbox-basic": "input", "textarea-basic": "input",
"v2-aggregation-widget": "stats", "aggregation-widget": "stats",
+1 -2
View File
@@ -36,8 +36,7 @@ const LEGACY_TO_UNIFIED: Record<string, string> = {
'button-primary': 'button',
'v2-table-search-widget': 'search',
'table-search-widget': 'search',
'v2-input': 'input',
'v2-select': 'input',
// V2 입력/선택 폐기 (Phase D.2, 2026-05-12) — runtime fallback 매핑 제거.
'text-input': 'input',
'number-input': 'input',
'date-input': 'input',
+62 -41
View File
@@ -1,5 +1,5 @@
/**
* V2
* INVYONE canonical
*/
export interface WebTypeMapping {
@@ -9,12 +9,12 @@ export interface WebTypeMapping {
}
export interface V2ComponentMapping {
componentType: string; // v2-input, v2-select, input(date/datetime/time/daterange) 등
componentType: string; // input/table/button 등 canonical component id
config: Record<string, any>; // 컴포넌트별 기본 설정
}
/**
* V2
* INVYONE canonical
*/
export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
// 텍스트 입력 계열 → InputComponent (InvField canonical)
@@ -81,48 +81,71 @@ export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
config: { kind: "choice", type: "single", format: "list", options: [] },
},
radio: {
componentType: "v2-select",
config: { mode: "radio", source: "static", options: [] },
componentType: "input",
config: { kind: "choice", type: "single", format: "list", mode: "radio", source: "static", options: [] },
},
checkbox: {
componentType: "v2-select",
config: { mode: "checkbox", source: "static", options: [] },
componentType: "input",
config: { kind: "choice", type: "multi", format: "list", mode: "check", source: "static", options: [] },
},
boolean: {
componentType: "v2-select",
config: { mode: "toggle", source: "static" },
componentType: "input",
config: { kind: "choice", type: "single", format: "boolean", mode: "toggle", inputType: "boolean" },
},
// 코드/참조 → V2Select (소스: code)
// 코드/참조 → InputComponent (InvField canonical)
code: {
componentType: "v2-select",
config: { mode: "dropdown", source: "code", codeGroup: "" },
componentType: "input",
config: { kind: "choice", type: "single", format: "code", mode: "dropdown", source: "category", inputType: "category", codeGroup: "" },
},
// 엔티티/참조 테이블 → V2Select (소스: entity)
// 엔티티/참조 테이블 → InputComponent (InvField canonical)
entity: {
componentType: "v2-select",
config: { mode: "dropdown", source: "entity", searchable: true },
componentType: "input",
config: { kind: "choice", type: "single", format: "entity", mode: "dropdown", source: "entity", inputType: "entity", searchable: true },
},
// 카테고리 → V2Select (소스: category)
// 카테고리 → InputComponent (InvField canonical)
category: {
componentType: "v2-select",
config: { mode: "dropdown", source: "category" },
componentType: "input",
config: { kind: "choice", type: "single", format: "code", mode: "dropdown", source: "category", inputType: "category" },
},
// 파일/이미지 → V2 파일 업로드
// 파일/이미지 → InputComponent (canonical input) — type=file, format 으로 image/file 분기
file: {
componentType: "v2-file-upload",
config: { multiple: true, accept: "*/*", maxFiles: 10 },
componentType: "input",
config: {
kind: "attach",
type: "file",
format: "file",
accept: "*/*",
multiple: true,
maxFiles: 10,
},
},
image: {
componentType: "v2-file-upload",
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
componentType: "input",
config: {
kind: "attach",
type: "file",
format: "image",
accept: "image/*",
multiple: false,
maxFiles: 1,
showPreview: true,
},
},
img: {
componentType: "v2-file-upload",
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
componentType: "input",
config: {
kind: "attach",
type: "file",
format: "image",
accept: "image/*",
multiple: false,
maxFiles: 1,
showPreview: true,
},
},
// 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용)
@@ -154,26 +177,24 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
datetime: "input",
time: "input",
daterange: "input",
// select 계열은 Phase B (select-pickers 모듈 도입 후) 에서 input 통합
// single dropdown → input (Phase B.1 완료). multi/category/entity 는 Phase B.2~
select: "input",
dropdown: "input",
checkbox: "v2-select",
radio: "v2-select",
boolean: "v2-select",
code: "v2-select",
entity: "v2-select",
category: "v2-select",
// file 은 Phase B 후 input 통합
file: "v2-file-upload",
image: "v2-file-upload",
img: "v2-file-upload",
checkbox: "input",
radio: "input",
boolean: "input",
code: "input",
entity: "input",
category: "input",
// file / image / img → canonical input (Phase D.4, 2026-05-12)
file: "input",
image: "input",
img: "input",
button: "button-primary",
label: "input",
};
/**
* V2 ID로
* canonical ID로
*/
export function getComponentIdFromWebType(webType: string): string {
const mapping = WEB_TYPE_V2_MAPPING[webType];
@@ -187,7 +208,7 @@ export function getComponentIdFromWebType(webType: string): string {
}
/**
* V2
* canonical
*/
export function getV2ConfigFromWebType(webType: string): Record<string, any> {
const mapping = WEB_TYPE_V2_MAPPING[webType];
@@ -201,7 +222,7 @@ export function getV2ConfigFromWebType(webType: string): Record<string, any> {
}
/**
* V2
* canonical
*/
export function getV2MappingFromWebType(webType: string): V2ComponentMapping {
const mapping = WEB_TYPE_V2_MAPPING[webType];
@@ -220,7 +241,7 @@ export function getV2MappingFromWebType(webType: string): V2ComponentMapping {
}
/**
* V2
* canonical
*/
export function createV2ConfigFromColumn(column: {
widgetType: string;
+3 -1
View File
@@ -1518,7 +1518,9 @@ html.vt-color-changing .v5-admin-btn{
V5 NUMBERING RULE MANAGEMENT (v5-nrm-*) 통짜 작업대 레이아웃
채번 = 독립 자원 · 컬럼 N:M 연결 · 카드 X · 통짜 + 2-col split
=================================================================== */
.v5-nrm{display:flex;flex-direction:column;height:100%;background:var(--v5-surface-solid);overflow:hidden;}
/* .v5-nrm 자체에는 background 깔아서 body .dark radial-gradient (globals.css) 비치게 .
sidebar main surface-solid 깔고, 헤더 영역은 투명 테마 컬러가 자연스럽게 헤더에 비침. */
.v5-nrm{display:flex;flex-direction:column;height:100%;overflow:hidden;}
.v5-nrm-body{flex:1;min-height:0;display:grid;grid-template-columns:320px 1fr;overflow:hidden;}
/* ── 좌측 sidebar ── */
+21 -5
View File
@@ -28,15 +28,15 @@ export type FieldType =
| 'time' // 시간만
| 'daterange' // 기간 (시작 ~ 끝)
| 'select' // 드롭다운 (options 배열)
| 'entity' // FK 참조 (팝업 검색)
| 'entity' // 참조 테이블 code-name 선택
| 'checkbox' // 체크박스
| 'textarea' // 장문
| 'file' // 파일 첨부
| 'code'; // 자동채번 (readonly)
/**
* entity FK .
* · · .
* entity .
* DB FK · · .
*/
export interface FieldRef {
/** 참조 대상 테이블 */
@@ -45,8 +45,24 @@ export interface FieldRef {
valueColumn: string;
/** 화면에 표시할 컬럼 */
displayColumn: string;
/** 팝업 검색 대상 컬럼 목록 */
/** 검색 가능한 표시 컬럼 목록 (명시 검색 UI가 사용할 수 있음) */
searchColumns?: string[];
/**
* .
* key = , value = target .
* : `{ "customer_name": "buyer_name", "phone": "buyer_phone" }`
* entity row customer_name buyer_name set.
*/
autoFillMap?: Record<string, string>;
/**
* . : `{ "is_active": "Y" }` .
*/
filter?: Record<string, any>;
/**
* UI . displayColumn .
* : `["customer_code", "customer_name", "ceo_name", "phone"]`
*/
modalColumns?: string[];
}
/**
@@ -112,7 +128,7 @@ export interface FieldConfigCommonOptions {
export interface FieldConfigTypeOptions {
/** select 타입: 선택지 목록 */
options?: FieldOption[];
/** entity 타입: FK 참조 정보 */
/** entity 타입: 참조 테이블 code-name 정보 */
ref?: FieldRef;
/** 포맷 문자열 (number: '#,##0', date: 'YYYY-MM-DD' 등) */
format?: string;
+1 -1
View File
@@ -38,7 +38,7 @@ export type DateFormat =
*/
export interface CategoryFormatMapping {
category_value_id: number; // 카테고리 값 ID
category_value_code?: string; // 카테고리 값 코드 (V2Select에서 valueCode 사용 시 매칭용)
category_value_code?: string; // 카테고리 값 코드 (canonical input 의 카테고리 옵션 매칭용)
category_value_label: string; // 카테고리 값 라벨 (표시용)
category_value_path?: string; // 전체 경로 (예: "원자재/벌크/가스켓")
format: string; // 생성할 형식 (예: "ITM", "VLV")
+6 -2
View File
@@ -1,3 +1,7 @@
// Legacy compatibility shim — re-exports from v2-components
export type { SelectOption } from "./v2-components";
// Legacy compatibility shim — UnifiedSelect 가 사용하는 최소 타입만 유지.
// 옛 V2 입력/선택 본체 (Phase D.3 폐기) 와 분리됨.
export interface SelectOption {
value: string;
label: string;
}
export type UnifiedSelectProps = any;
+15 -176
View File
@@ -1,18 +1,18 @@
/**
* V2
* V2 ( 7)
*
* 9
* - V2Input
* - V2Select
* - V2Text
* - V2Media
* - V2List
* - V2Layout
* - V2Group
* - V2Biz
* - V2Hierarchy
*
* V2Date InvField triple type=date 4 format(date/datetime/time/range) .
* / Phase D.3 (2026-05-12) canonical `input`
* (InputComponent + InvFieldConfigPanel) .
* V2Date InvField triple type=date 4 format(date/datetime/time/range) .
* InputComponent + lib/registry/components/input/pickers.tsx.
*/
@@ -24,10 +24,7 @@ import { Position, Size, CommonStyle, ValidationRule } from "./v2-core";
* V2
*/
export type V2ComponentType =
| "V2Input"
| "V2Select"
| "V2Text"
| "V2Media"
| "V2List"
| "V2Layout"
| "V2Group"
@@ -100,110 +97,10 @@ export interface V2BaseProps {
isDesignMode?: boolean;
}
// ===== V2Input =====
export type V2InputType = "text" | "number" | "password" | "slider" | "color" | "button";
export type V2InputFormat = "none" | "email" | "tel" | "url" | "currency" | "biz_no";
export interface V2InputConfig {
type: V2InputType;
input_type?: V2InputType; // type 별칭
format?: V2InputFormat;
mask?: string;
placeholder?: string;
// 숫자 전용
min?: number;
max?: number;
step?: number;
// 버튼 전용
button_text?: string;
button_variant?: "default" | "destructive" | "outline" | "secondary" | "ghost";
onClick?: () => void;
// 테이블명 (채번용)
table_name?: string;
}
export interface V2InputProps extends V2BaseProps {
v2Type: "V2Input";
config: V2InputConfig;
value?: string | number;
onChange?: (value: string | number) => void;
}
// ===== V2Select =====
export type V2SelectMode = "dropdown" | "combobox" | "radio" | "check" | "tag" | "tagbox" | "toggle" | "swap";
export type V2SelectSource = "static" | "code" | "db" | "api" | "entity" | "category";
export interface SelectOption {
value: string;
label: string;
}
/**
* V2Select
* WHERE
*/
export interface V2SelectFilter {
column: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like" | "isNull" | "isNotNull";
/** 값 유형: static=고정값, field=다른 폼 필드 참조, user=로그인 사용자 정보 */
value_type?: "static" | "field" | "user";
/** static일 때 고정값 */
value?: unknown;
/** field일 때 참조할 폼 필드명 (column_name) */
field_ref?: string;
/** user일 때 참조할 사용자 필드 */
user_field?: "companyCode" | "userId" | "deptCode" | "userName";
}
export interface V2SelectConfig {
mode: V2SelectMode;
source: V2SelectSource | "distinct" | "select"; // distinct/select 추가 (테이블 컬럼에서 자동 로드)
// 정적 옵션 (source: static)
options?: SelectOption[];
// 코드 그룹 (source: code)
code_group?: string;
code_category?: string; // code_group 별칭
// DB 연결 (source: db)
table?: string;
value_column?: string;
label_column?: string;
// 옵션 필터 조건 (모든 source에서 사용 가능)
filters?: V2SelectFilter[];
// 엔티티 연결 (source: entity)
entity_table?: string;
entity_value_field?: string;
entity_label_field?: string;
entity_value_column?: string; // alias for entity_value_field
entity_label_column?: string; // alias for entity_label_field
// API 연결 (source: api)
api_endpoint?: string;
// 카테고리 연결 (source: category) - 레거시, code로 자동 변환됨
category_table?: string;
category_column?: string;
// 공통 옵션
searchable?: boolean;
multiple?: boolean;
max_select?: number;
allow_clear?: boolean;
// 연쇄 관계
cascading?: CascadingConfig;
// 상호 배제
mutual_exclusion?: MutualExclusionConfig;
// 계층 코드 연쇄 선택 (source: code일 때 계층 구조 사용)
hierarchical?: boolean; // 계층 구조 사용 여부
parent_field?: string; // 부모 값을 참조할 필드 (다른 컴포넌트의 column_name)
}
export interface V2SelectProps extends V2BaseProps {
v2Type: "V2Select";
config: V2SelectConfig;
value?: string | string[];
onChange?: (value: string | string[]) => void;
onFormDataChange?: (fieldName: string, value: any) => void;
form_data?: Record<string, any>;
}
// 옛 입력/선택 타입 정의는 Phase D.3 (2026-05-12) 에서 제거됨.
// 입력 옵션은 canonical InputConfig (lib/registry/components/input/types.ts) 와
// FieldConfig (types/invyone-component.ts) 로 일원화. 옵션 필터는
// OptionFilter (lib/registry/components/input/use-option-loader.ts) 로 이전됨.
// ===== V2Text =====
@@ -224,39 +121,7 @@ export interface V2TextProps extends V2BaseProps {
onChange?: (value: string) => void;
}
// ===== V2Media =====
export type V2MediaType = "file" | "image" | "video" | "audio";
export interface V2MediaConfig {
type: V2MediaType;
multiple?: boolean;
accept?: string;
max_size?: number;
preview?: boolean;
upload_endpoint?: string;
// 레거시 FileUpload 호환 설정
doc_type?: string;
doc_type_name?: string;
show_file_list?: boolean;
drag_drop?: boolean;
}
export interface V2MediaProps extends V2BaseProps {
v2Type?: "V2Media";
config?: V2MediaConfig;
value?: string | string[]; // 파일 URL 또는 배열
onChange?: (value: string | string[]) => void;
// 레거시 FileUpload 호환 props
form_data?: Record<string, any>;
column_name?: string;
table_name?: string;
// 부모 컴포넌트 시그니처: (fieldName, value) 형식
onFormDataChange?: (fieldName: string, value: any) => void;
isDesignMode?: boolean;
isInteractive?: boolean;
onUpdate?: (updates: Partial<any>) => void;
}
// V2Media 타입 정의는 Phase D.5 (2026-05-12) 에서 제거됨 — canonical input 의 file 분기로 흡수.
// ===== V2List =====
@@ -425,10 +290,7 @@ export interface V2HierarchyProps extends V2BaseProps {
// ===== 통합 Props 유니온 타입 =====
export type V2ComponentProps =
| V2InputProps
| V2SelectProps
| V2TextProps
| V2MediaProps
| V2ListProps
| V2LayoutProps
| V2GroupProps
@@ -437,21 +299,11 @@ export type V2ComponentProps =
// ===== 타입 가드 =====
export function isV2Input(props: V2ComponentProps): props is V2InputProps {
return props.v2Type === "V2Input";
}
export function isV2Select(props: V2ComponentProps): props is V2SelectProps {
return props.v2Type === "V2Select";
}
export function isV2Text(props: V2ComponentProps): props is V2TextProps {
return props.v2Type === "V2Text";
}
export function isV2Media(props: V2ComponentProps): props is V2MediaProps {
return props.v2Type === "V2Media";
}
// isV2Media 는 Phase D.5 에서 제거됨 (canonical input 의 file 분기로 흡수)
export function isV2List(props: V2ComponentProps): props is V2ListProps {
return props.v2Type === "V2List";
@@ -495,24 +347,12 @@ export interface V2ConfigSchema {
// ===== 레거시 컴포넌트 → V2 컴포넌트 매핑 =====
export const LEGACY_TO_V2_MAP: Record<string, V2ComponentType> = {
// Input 계열
"text-input": "V2Input",
"number-input": "V2Input",
"password-input": "V2Input",
// Select 계열
"select-basic": "V2Select",
"radio-basic": "V2Select",
"checkbox-basic": "V2Select",
"entity-search-input": "V2Select",
"autocomplete-search-input": "V2Select",
// Input / Select 계열은 canonical `input` 으로 흡수됨 (Phase D.3) — 이 매핑에서 제거.
// Text 계열
"textarea-basic": "V2Text",
// Media 계열
"file-upload": "V2Media",
"image-widget": "V2Media",
// Media 계열 — Phase D.5 에서 canonical input 으로 흡수, 매핑 제거.
// List 계열
"table-list": "V2List",
@@ -536,8 +376,7 @@ export const LEGACY_TO_V2_MAP: Record<string, V2ComponentType> = {
"numbering-rule": "V2Biz",
"flow-widget": "V2Biz",
// Button (Input의 버튼 모드)
"button-primary": "V2Input",
// Button (Input 모드) — canonical `input` 으로 흡수됨 (Phase D.3), 매핑 제거
};
// ===== 조건부 레이어 시스템 =====
@@ -2,27 +2,27 @@
날짜: 2026-05-07 ~ 2026-05-11
작업자: gbpark
컨텍스트: 인비원스튜디오의 입력 컴포넌트 (input / v2-input / v2-select / 옛 6개) 가 분산되어 외형·모델 들쭉날쭉. **canonical 1안 (InvFieldConfigPanel + InputComponent)** 으로 통합 중.
컨텍스트: 인비원스튜디오의 입력 계열을 **FieldConfig / DataPort 계약을 지키는 canonical `input`** 으로 통합 중. 설정 패널은 계약을 편집하는 UI일 뿐, 진실의 원천은 `frontend/types/invyone-component.ts` 의 FieldConfig / DataPort.
---
## 0. 핵심 원칙
- INVYONE = VEX 의 2세대 리뉴얼. **운영 단계 아님** → 옛 키 fallback / 호환 부담 X. 깨끗한 canonical 1안.
- 옛것이 남아있으면 통합 아님. **1 패널 1 컴포넌트**.
- FieldConfig 가 유일한 필드 규격이고, DataPort / Connection 이 컴포넌트 통신 계약.
- 옛것이 남아있으면 통합 아님. V2 입력/선택은 구현체·alias·fallback·DB 마이그 대상이 아니라 제거 대상.
- GPT-5.5 (codex:rescue) 와 단계마다 교차 검증.
## 1. 큰 그림 (목표 형태)
```
[8 통합 컴포넌트] = [8 단일 ConfigPanel + 단일 캔버스 컴포넌트]
input → InvFieldConfigPanel + InputComponent (text/number/money/date/single/multi/autonum/formula/audit/file)
[8 통합 컴포넌트] = [FieldConfig/DataPort 계약을 소비하는 단일 캔버스 컴포넌트]
input → FieldConfig + DataPort + InputComponent (text/number/money/date/single/multi/autonum/formula/audit/file)
table → InvTableConfigPanel
search · button · title · divider · stats · container
[옛 V2 / 옛 6개 컴포넌트] → 기능 이식 후 폐기
V2Input.tsx (1286줄)
V2Select.tsx (1350줄)
옛 입력/선택 본체 2개 — 삭제 완료, 필요한 기능은 canonical input 으로 흡수
date-input / text-input / number-input / select-basic / checkbox-basic / textarea-basic 의 자체 캔버스 컴포넌트 6개
```
@@ -49,7 +49,7 @@
- ✅ `DynamicComponentRenderer.tsx:418-453` 의 fieldType / dbInputType → v2-input/v2-select **강제 swap 분기 통째 제거**
- ✅ `webTypeMapping.ts` 의 text 계열 9개 (text/email/password/tel/url/textarea/number/decimal/label) → `input`
- ✅ 기 fallback 도 `v2-input` `input`
- ✅ 기 fallback 도 `input` canonical 로 정리했고, Phase D.2 에서 V2 입력/선택 fallback 자체 제거
### Phase 4 — InputComponent 외형 통일
@@ -96,7 +96,7 @@
- ✅ `RadioPicker` 신규 — single + mode=radio (라디오 button list)
- ✅ `CheckboxListPicker` 신규 — multi + mode=check (체크박스 list, maxSelect 차단)
- ✅ InputComponent select 분기 — mode 따라 4 picker 분기 (Single/Multi/Radio/CheckboxList)
- ✅ ConfigPanel "선택 방식" 옵션을 multi prop 따라 분기 — single: dropdown/combobox/radio/toggle, multi: dropdown/combobox/check/tag
- ✅ ConfigPanel "선택 방식" 옵션을 multi prop 따라 분기 — single: dropdown/combobox/radio/toggle, multi: dropdown/combobox/check/swap/tag
- ✅ TYPE_VOLATILE_FIELDS 에 `maxSelect` 추가 (`mode` 는 기존)
### Phase B.4 추가 정리 — 외각 box + 복수 선택 토글
@@ -117,6 +117,355 @@
## 2026-05-11 진행 (이번 세션)
### Phase C 보정 — entity 는 code-name 옵션 source (2026-05-12)
**배경**: 1차 C에서 `format=entity` 를 검색 모달로 해석했으나 사용자 점검으로 계약 오류 확인.
INVYONE input 의 entity 는 DB FK 를 직접 걸 수 없는 경우 화면 설정에서 참조 테이블과
저장 값/표시 라벨 컬럼을 지정해 code-name 옵션을 가져오는 선택형이다.
검색 모달은 별도 `entity-search-input` 계열의 책임이지 canonical input entity 의 기본 동작이 아니다.
**변경**:
- ✅ `useOptionLoader``source=entity``/entity/{entityTable}/options?value={entityValueColumn}&label={entityLabelColumn}` 로 로드
- `config.ref.table/valueColumn/displayColumn` 도 같은 계약으로 처리
- ✅ `InputComponent``type=single|multi + format=entity` 는 select 계열 picker 로 렌더
- 단일 entity: `SingleSelectPicker` (검색 가능 dropdown)
- 다중 entity: `MultiSelectPicker` / `mode=check` / `mode=swap` 지원
- ✅ 잘못 추가된 canonical input 전용 `EntityPicker` 모달 경로 제거
- ✅ `V2PropertiesPanel` / `InvFieldConfigPanel` — 참조 테이블 목록에서
`table_name/display_name``tableName/displayName` 을 모두 정규화
**미구현 (후속)**:
- 옵션 필터 (`filters`) 의 runtime query 전달
### Phase C.2 — v2-input/v2-select 재유입 차단 (2026-05-12)
**배경**: 목표는 V2 입력/선택 컴포넌트를 계속 쓰는 게 아니라, 필요한 기능만 `input` canonical 로 흡수하는 것. 그런데 webType 매핑과 renderer 우회로가 여전히 `v2-select` 를 생성/호출하고 있었음.
**변경**:
- ✅ `webTypeMapping.ts` — radio/checkbox/boolean/code/entity/category 모두 `componentType: "input"` 으로 변경
- ✅ `DynamicComponentRenderer.tsx` — category 고급 모드에서 `V2SelectRenderer` 를 직접 require 하던 분기 제거
- ✅ `components/index.ts``v2-input/V2InputRenderer`, `v2-select/V2SelectRenderer` auto-register import 제거
- ✅ `registerV2Components.ts``v2-input`, `v2-select` 레지스트리 등록 제거
- ✅ `InputComponent.tsx``format="entity"` 는 select 계열 picker 로 라우팅
- ✅ `useOptionLoader.ts``entityTable/entityValueColumn/entityLabelColumn` 로 code-name options 로드
- ✅ `ScreenSettingModal.tsx` — 폼 필드 자동 추가 시 `text-input` 대신 `input` canonical 생성
**검증**:
- `git diff --check` 통과
- `npx tsc --noEmit --pretty false` 는 기존 전역 타입 오류로 실패. 변경 파일 필터 기준 신규 오류 없음.
### Phase C — entity 선택형 보정 (2026-05-12 완료)
**배경**: input entity 는 마스터 데이터 (거래처/품목/사원) 의 참조 테이블에서 저장 값(code)과
표시 라벨(name)을 지정해 옵션으로 가져오는 기능. DB FK 강제 대신 화면 레벨에서 code-name 참조를 건다.
**변경**:
- ✅ `InputComponent.tsx` — case "entity" 는 모달이 아니라 `SingleSelectPicker` / `MultiSelectPicker`
- ✅ `useOptionLoader.ts` — entity source option loading 추가
- ✅ `frontend/lib/registry/components/input/entity-picker.tsx` 삭제 — canonical input 에 모달 검색 재유입 방지
**관련 파일 맵 업데이트**:
- canonical: `frontend/lib/registry/components/input/use-option-loader.ts``source=entity`
- 별도 검색 UI: `lib/registry/components/entity-search-input/` 는 명시 검색 컴포넌트가 필요할 때만 사용
### Phase C.3 — entity code-name 안정화 (2026-05-12)
**배경**: 1차 C 후 사용자 점검 — InvFieldConfigPanel 의 entity 설정 UI 에서 참조 테이블 dropdown 이
가끔 비고, value/label 컬럼을 바꾼 뒤에도 옵션이 갱신되지 않는 케이스 확인. 검색 모달 재유입은
없어야 한다는 계약은 그대로 유지.
**점검 결과 — 이미 정상**:
- `V2PropertiesPanel.normalizeConfigPanelTables``tableName/displayName``table_name/display_name` 모두 흡수.
InvFieldConfigPanel 에 `tables` / `allTables` 정규화된 값 전달
- `InvFieldConfigPanel.normalizeTableSelectOptions` — 동일하게 양쪽 키 흡수. EntityOptions 호출 시
`allTables.length > 0 ? allTables : tables` 폴백
- `InvFieldConfigPanel.loadColumnsForTable``columnName/column_name` + `displayName/display_name` 흡수
- `InputComponent.tsx` case "entity" — `SingleSelectPicker` / `MultiSelectPicker` / `CheckboxListPicker` /
`SwapPicker` 로만 라우팅. 모달 / EntityPicker import 없음
- `useOptionLoader.ts` source=entity — `/entity/{entityTable}/options?value={...}&label={...}` 로 fetch.
응답 shape (`data[]`, `data.success+data`, `data.options`) 정상 normalize
**수정 — fetchUrl dep 누락 (옵션 갱신 안 되는 진짜 원인)**:
- `use-option-loader.ts``fetchUrl` useMemo 의존성 배열에 다음 6개 추가:
- `config.entityTable` / `config.entityValueColumn` / `config.entityLabelColumn`
- `config.ref?.table` / `config.ref?.valueColumn` / `config.ref?.displayColumn`
- ref 객체 자체를 dep 으로 넣지 않은 이유: 객체 reference 변동만으로 effect 폭주를 방지하기 위해
내부 키만 추출
**디자인 모드 가드**:
- `useOptionLoader``isDesignMode === true``needsFetch = false` 로 fetch 자체 skip
- `InvFieldConfigPanel` 의 테이블/컬럼 목록 로드는 별도 경로 (apiClient 직접 호출, isDesignMode 무관)
→ 설정 패널 동작은 그대로 허용
**canonical input 에 모달 재유입 없음 (확인)**:
- `rg "EntityPicker|entity-picker|EntitySearchModal" frontend/lib/registry/components/input frontend/components/v2/config-panels/InvFieldConfigPanel.tsx` → 0건
### Phase C.4 — option filters runtime 적용 (2026-05-12)
**배경**: `OptionFilter[]` 가 InvFieldConfigPanel 에 저장만 되고 runtime 에 반영되지 않았음.
canonical input 의 entity / distinct / db source 에서 filter 를 실제 query 로 전달해 결과 옵션을 좁힌다.
검색 모달 재유입 / v2-input·v2-select 재도입 / FieldConfig·DataPort 축소는 모두 금지.
**변경**:
- ✅ `use-option-loader.ts``OptionLoaderConfig.filters?: OptionFilter[]` 추가
- ✅ `OptionUserContext` 타입 신규 — `companyCode / userId / deptCode / userName`
- ✅ `UseOptionLoaderArgs.userContext` 추가 — `value_type === "user"` 필터 치환용
- ✅ `resolveFilters(filters, formData, userContext)` 헬퍼 — 치환 규칙:
- `column` trim 후 빈 값 → skip
- `value_type=static``f.value` 그대로
- `value_type=field``formData[field_ref]`, 값 없으면 skip
- `value_type=user``userContext[user_field]`, 값 없으면 skip
- `isNull / isNotNull` → value 없이 통과
- `in / notIn` → 배열 또는 `"a,b,c"` 문자열 모두 trim 된 배열로 정규화
- ✅ `resolvedFiltersJson` useMemo — runtime 치환 결과를 `JSON.stringify` 로 stable string 화.
결과가 빈 배열이면 `undefined` (URL 에 query 자체 미추가)
- ✅ `fetchUrl` useMemo — `filters=` URL-encoded JSON 으로 append:
- **entity**: `/entity/{table}/options?value={v}&label={l}&filters={...}` (1순위)
- **distinct / select**: `/entity/{table}/distinct/{column}?filters={...}`
- **db (legacy)**: `/entity/{table}/options?value={v}&label={l}&filters={...}`
- **code / category / api**: 백엔드 endpoint 가 filter 스펙을 정의하지 않아 미적용 (TODO 주석 명시)
- ✅ `fetchUrl` dep 배열에 `resolvedFiltersJson` 추가 → filter 값 변경 시 URL 재생성 → cache key 자동 신규화
- ✅ `resolvedFiltersJson` 자체의 dep — `config.filters`, `formData`, `userContext` 4 키 (객체 reference 폭주 방지)
- ✅ `InputComponent.tsx` — props 에서 `companyCode / user_id / dept_code / user_name` 등 camel/snake 모두 흡수해
`userContext` 객체 구성 후 `useOptionLoader` 에 전달. DOM noise 누설 방지를 위해 destructure 목록에 4개 추가
**filter 치환 예시 — entity URL**:
```
config.entityTable = "tb_customer";
config.entityValueColumn = "customer_code";
config.entityLabelColumn = "customer_name";
config.filters = [
{ column: "is_active", operator: "=", value_type: "static", value: "Y" },
{ column: "company_code", operator: "=", value_type: "user", user_field: "companyCode" },
];
// runtime 사용자 companyCode = "C0001"
→ /entity/tb_customer/options?value=customer_code&label=customer_name
&filters=%5B%7B%22column%22%3A%22is_active%22%2C%22operator%22%3A%22%3D%22%2C%22value%22%3A%22Y%22%7D%2C%7B%22column%22%3A%22company_code%22%2C%22operator%22%3A%22%3D%22%2C%22value%22%3A%22C0001%22%7D%5D
```
**cache / race / design mode 영향**:
- **cache**: 기존 `responseCache: Map<url, options>`. filters 값이 바뀌면 URL 이 신규 → cache key 자동 신규.
이전 결과는 그대로 보존되어 같은 필터로 돌아오면 즉시 hit
- **race**: 기존 `reqIdRef` + `cancelled` flag 유지. URL 변경 시 in-flight 요청 결과 무시
- **design mode**: 기존 `isDesignMode === true``needsFetch=false` 로 fetch skip 흐름 그대로. filter resolver
는 dep 평가 단계에서만 도는 정도이고 API 호출 자체가 없음
**InvFieldConfigPanel UI**: 이미 filters 입력 UI 존재. 이번 단계는 runtime 전달만 보완, UI/문구 변경 없음.
**검증**:
- `git diff --check` — whitespace 0
- `rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles` — 0건
- `rg "EntityPicker|entity-picker|EntitySearchModal" frontend/lib/registry/components/input frontend/components/v2/config-panels/InvFieldConfigPanel.tsx` — 0건
- `tsc` 변경 파일 (`use-option-loader`, `InputComponent`) 신규 오류 0건
### Phase D.4 — file / image / img → canonical input 통합 (2026-05-12)
**배경**: webTypeMapping 의 `file / image / img` 와 DynamicWebTypeRenderer 의 강제 분기가
여전히 `v2-file-upload / FileUploadComponent / ImageWidget` 로 빠지고 있었음. canonical input
에 file 분기는 기존부터 있었지만 native `<input type=file>` 단독이라 미리보기 / 다중 칩 / 삭제
UX 가 빠져 있었음. 옛 본체 파일 삭제는 사용처 0건 확인 후 별도 phase.
**변경**:
- ✅ `frontend/lib/registry/components/input/file-picker.tsx` 신규 — `FilePicker`
- props: `value / onChange / accept / multiple / maxFiles / disabled / readonly / placeholder / showPreview / className`
- 값 형태: `File | string | Array<File | string> | null` (서버 저장된 path 문자열도 흡수)
- 이미지면 `URL.createObjectURL` preview thumbnail (cleanup 효과 포함, 메모리 누수 방지)
- SSR/Node 경로에서 `File` 전역이 없을 수 있어 `typeof File !== "undefined"` 가드 적용
- 그 외 파일은 `FileText` 아이콘 + 파일명 (max-w-160 truncate + tooltip)
- 다중 시 `maxFiles` 초과 자르기 + 한도 도달 시 `+추가` 버튼 disabled
- `disabled / readonly``lockEdit` 통합 (선택/삭제/추가 모두 차단)
- 같은 파일 재선택 가능하도록 hidden input value 매번 초기화
- ✅ `frontend/lib/registry/components/input/types.ts``InputConfig``maxFiles / showPreview` 추가
- ✅ `frontend/lib/registry/components/input/InputComponent.tsx` — case "file"
- native `<input type=file>` 단독 → **FilePicker** 호출
- `format === "image"` 면 자동 `accept="image/*"` + `showPreview=true`
- `format === "doc"``.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx`
- 사용자 명시 `accept / showPreview` 가 있으면 그 값 우선
- fallback webType 에 `img / picture / photo` 도 file 로 흡수
- ✅ `frontend/lib/utils/webTypeMapping.ts`
- `file / image / img``WEB_TYPE_V2_MAPPING` 항목 → `componentType: "input"` 으로 변경
- config triple: `{ kind: "attach", type: "file", format: "file" | "image", accept, multiple, maxFiles, showPreview }`
- `WEB_TYPE_COMPONENT_MAPPING``file/image/img → "input"`
- ✅ `frontend/lib/registry/DynamicWebTypeRenderer.tsx`
- `webType === "file" / "image" / "img" / "picture" / "photo"``props.component?.type === "file"` 모두
canonical `InputComponent` 로 라우팅 (file config triple 주입). 옛 `FileUploadComponent` / `ImageWidget`
직접 import 분기 3곳 제거 (참조만 끊고 파일 자체는 잔존 — 별도 phase)
- 기존 타입 mismatch 보정: `webType/web_type` 양쪽 prop 흡수, `defaultConfig``default_config`
- ✅ `frontend/components/screen/panels/ComponentsPanel.tsx` — hidden 목록 주석 갱신
- "→ V2Media" / "→ v2-media (image)" / "통합 미디어" 등을 "→ canonical input (type='file', format='image')" 로 우회
- `v2-media` / `v2-file-upload` 두 항목은 hidden 유지 (생성 차단)
- ✅ `notes/...` 본 노트 — Phase D.4 기록
**file / image / img 가 이제 어떤 config 로 input 생성되는지 예시**:
```jsonc
// webType = "file"
{
"componentType": "input",
"config": {
"kind": "attach",
"type": "file",
"format": "file",
"accept": "*/*",
"multiple": true,
"maxFiles": 10
}
}
// webType = "image" 또는 "img"
{
"componentType": "input",
"config": {
"kind": "attach",
"type": "file",
"format": "image",
"accept": "image/*",
"multiple": false,
"maxFiles": 1,
"showPreview": true
}
}
```
**FieldConfig / DataPort 영향**: **0건**
- `frontend/types/invyone-component.ts` 의 FieldType / FieldConfig / DataPort / Connection 변경 없음
- `frontend/lib/dataPort/runtime.ts` 변경 없음
- 추가된 `InputConfig.maxFiles` / `InputConfig.showPreview` 는 input 컴포넌트 내부 옵션 (FieldConfig 와는 별개 — InputComponent 의 InvField triple 흡수 후 props 분리)
**남은 old file/media 삭제 후보 (다음 phase)**:
- `frontend/lib/registry/components/file-upload/FileUploadComponent.tsx` + `index.ts` + `FileUploadConfigPanel.tsx`
- `frontend/lib/registry/components/v2-file-upload/` 폴더 통째
- `frontend/lib/registry/components/v2-media/` 폴더 통째 (`V2MediaRenderer` + config 패널)
- `frontend/components/v2/V2Media.tsx` 본체 + V2Media 관련 타입 / 데모 / 매핑
- `frontend/lib/registry/components/image-widget/` + `image-display/`
- `frontend/components/screen/widgets/types/ImageWidget.tsx`
- **사전 확인 필요**: 위 컴포넌트들이 외부에서 import 되는 경로 grep 후 0건일 때만 삭제
**검증**:
- ✅ `git diff --check` — whitespace 0
- ✅ `rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer"` — 0건 유지
- ✅ `rg "componentType: \"v2-file-upload\"|file: \"v2-file-upload\"|image: \"v2-file-upload\"|img: \"v2-file-upload\""` — 새 생성/매핑 경로 **0건**
- ✅ `rg "EntityPicker|entity-picker|EntitySearchModal" canonical input 경로` — 0건 유지
- ✅ `tsc` 변경 파일 신규 오류 0건 (`DynamicWebTypeRenderer` 의 기존 `webType`/`defaultConfig` mismatch 도 함께 정리)
### Phase D.5 — canonical file 저장 파이프라인 + old file/media 런타임 차단 (2026-05-12)
**배경**: Phase D.4 에서 file/image/img 새 생성 경로는 canonical input 으로 통합. 본 phase 는
(1) canonical input file 값이 master save 흐름에서 누락되지 않게 보완 + (2) 옛 file-upload /
image-widget / image-display / v2-media / v2-file-upload 의 런타임 / registry / ConfigPanel
경로 차단. 옛 본체 파일 자체 삭제는 외부 import 정밀 분석 후 별도 phase.
**canonical file 저장값 변환 정책 (master save)**:
- `InteractiveScreenViewerDynamic.handleSaveAction``masterFormData` 구성 단계 보강
- 파일 컬럼 식별 (`isFileColumnComponent`):
- 옛: `componentType ∈ {v2-media, file-upload}` 또는 `url.includes(...)` (호환용)
- canonical: `componentType === "input"` && (`config.type === "file"` || `config.kind === "attach"` || `config.format ∈ {file, image, doc}`)
- 보정: `componentConfig / config / overrides` 세 저장 위치를 모두 흡수. 컬럼명도 `column_name / columnName` 양쪽 키를 같은 우선순위로 확인.
- 값 별 처리:
- `null / undefined` → 그대로
- `string / string[]` → 이미 저장된 path/id 로 보고 유지 (업로드 skip)
- `File / File[]``uploadFiles` (POST `/files/upload`) 호출 → 응답 `FileInfo[].id ?? server_path ?? server_filename` 로 치환
- 혼합 (`(File | string)[]`) → string 유지 + File 만 업로드 후 합쳐서 배열로
- 업로드 실패 시 안전하게 string 만 유지 (브라우저 `File` 객체가 saveData 에 들어가지 않도록)
- 파일 컬럼이 아닌 배열 값은 종전대로 repeater 로 보고 제외
- 파일 컬럼은 더 이상 repeater 오인 제외 대상이 아님
**제거한 old runtime / register / config 경로**:
- `InteractiveScreenViewerDynamic.tsx``FileUploadComponent` import 제거, `isFileComponent` import 제거,
`if (isFileComponent(comp)) return renderFileComponent(comp)` 분기 제거, `renderFileComponent` 함수 통째 제거,
`uploadFilesAndCreateData` 직접 import 제거 (`uploadFiles` 동적 import 가 master save 안에서 대체)
- `lib/registry/components/index.ts``file-upload/FileUploadRenderer`, `image-widget/ImageWidgetRenderer`,
`image-display/ImageDisplayRenderer`, `v2-media/V2MediaRenderer`, `v2-file-upload/V2FileUploadRenderer`
auto-register import 5개 모두 제거
- `lib/utils/getComponentConfigPanel.tsx``CONFIG_PANEL_MAP``v2-media / file-upload / image-display / image-widget` mapping 4개 제거
- `components/screen/panels/V2PropertiesPanel.tsx``v2ConfigPanels``v2-media` 매핑 제거
- `lib/schemas/componentConfig.ts``v2MediaOverridesSchema` 정의 + `componentOverridesRegistry``v2-media` 등록 + `componentDefaultsRegistry``v2-media` 기본값 모두 제거
- `components/v2/registerV2Components.ts``V2Media` / `V2MediaConfigPanel` import + v2-media `ComponentDefinition` 등록 제거
- `components/v2/V2ComponentRenderer.tsx``isV2Media` / `V2Media` import + `if (isV2Media(props)) return <V2Media>` 분기 제거
- `components/v2/V2ComponentsDemo.tsx``V2Media` import + media TabsTrigger + media TabsContent 통째 제거
- `components/v2/index.ts``V2Media` export + V2Media 타입 re-export (`V2MediaType / V2MediaConfig / V2MediaProps`) 제거
- `components/v2/config-panels/index.ts``V2MediaConfigPanel` export 제거
- `types/v2-components.ts``V2MediaType / V2MediaConfig / V2MediaProps` 인터페이스 + `isV2Media` 가드 + `V2ComponentType` / `V2ComponentProps` 유니온의 V2Media 멤버 + `LEGACY_TO_V2_MAP` 의 file-upload/image-widget → V2Media 매핑 제거
- `components/v2/V2Media.tsx` — D.6 삭제 전까지 전체 tsc 가 중앙 타입 제거로 바로 깨지지 않도록 로컬 orphan `V2MediaProps` 만 임시 정의. 런타임/register/config 경로는 이미 차단됨.
- `app/(main)/screens/[screenId]/page.tsx``compType?.includes("v2-media")` / `compType?.includes("file-upload")` 중복 매칭 제거 (이미 `includes("input")` 으로 흡수됨)
**보존한 shared file viewer 유틸**:
| 파일 | 보존 이유 |
|---|---|
| `frontend/lib/registry/components/file-upload/FileViewerModal.tsx` | `GlobalFileViewer.tsx`, `FileAttachmentDetailModal.tsx` 가 import. 단독 파일 뷰어 모달. canonical input 과 무관한 shared util |
| `frontend/lib/registry/components/file-upload/FileManagerModal.tsx` | `FileUploadComponent` (옛 본체) 가 사용. FileViewerModal 사용. 본체 삭제 시 함께 검토 |
| `frontend/lib/registry/components/file-upload/types.ts` (있다면) | FileViewerModal/FileManagerModal 의 타입 의존성 |
**검증**:
- ✅ `git diff --check` — 0건
- ✅ `rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer"` — 0건
- ✅ `rg "componentType: \"v2-file-upload\"|file: \"v2-file-upload\"|image: \"v2-file-upload\"|img: \"v2-file-upload\""` — 0건
- ✅ `rg "EntityPicker|entity-picker|EntitySearchModal" canonical input 경로` — 0건
- ✅ `rg "FileUploadComponent" frontend/lib frontend/components frontend/app frontend/types`:
- 잔존 매치는 모두 (a) 폴더 내부 자기 자신 정의 (`file-upload/`, `v2-file-upload/`) (b) 폴더 내부 V2Media 본체의 import (V2Media 도 폐기 표시됨) (c) Phase D.4/D.5 주석. **외부 runtime 직접 import 0건**
- ✅ `rg "V2Media|v2-media|V2FileUpload|v2-file-upload"`:
- 잔존은 폐기 폴더 내부 정의 / 옛 본체 (V2Media.tsx / V2FileUploadConfigPanel.tsx) / 폐기 주석. **외부 신규 runtime/config/register 참조 0건**
- ✅ `tsc` 변경 파일 신규 오류 0건 (전역 잔존 오류는 모두 기존 camelCase ↔ snake_case 미스매치)
**다음 phase 에서 실제 삭제 가능한 파일/폴더 (사전 import 0건 재확인 필요)**:
| 경로 | 사전 확인 명령 | 비고 |
|---|---|---|
| `frontend/lib/registry/components/v2-file-upload/` (FileViewerModal/FileManagerModal 제외 가능성 검토) | `rg "v2-file-upload\\|V2FileUpload"` | 폴더 자체 정의만 잔존 |
| `frontend/lib/registry/components/v2-media/` | `rg "v2-media\\|V2MediaRenderer"` | 폴더 자체 정의만 잔존 |
| `frontend/components/v2/V2Media.tsx` | `rg "V2Media\\b"` | 외부 import 0건 확인됨 |
| `frontend/components/v2/config-panels/V2MediaConfigPanel.tsx` | `rg "V2MediaConfigPanel"` | 외부 import 0건 확인됨 |
| `frontend/components/v2/config-panels/V2FileUploadConfigPanel.tsx` | `rg "V2FileUploadConfigPanel"` | 외부 사용 없음 |
| `frontend/lib/registry/components/image-widget/` | `rg "image-widget\\|ImageWidget"` | DynamicWebTypeRenderer 의 옛 ImageWidget import 는 Phase D.4 에서 제거됨 |
| `frontend/lib/registry/components/image-display/` | `rg "image-display"` | config panel mapping 제거됨 |
| `frontend/lib/registry/components/file-upload/FileUploadComponent.tsx`, `FileUploadRenderer.tsx`, `FileUploadConfigPanel.tsx` | 위 grep | **FileViewerModal / FileManagerModal / types 만 잔존시키고 본체 3 파일만 삭제** |
| `frontend/components/screen/widgets/types/ImageWidget.tsx` | `rg "ImageWidget"` | DynamicWebTypeRenderer 분기 제거됨 |
**Phase D.6 (제안)**: 위 후보를 각각 grep 0건 확인 후 `git rm`. `FileUploadComponent` 본체만 삭제하고 `FileViewerModal / FileManagerModal / types` 는 보존 (GlobalFileViewer / FileAttachmentDetailModal 의존성).
### Phase D.6 — old file/media 본체 실제 삭제 (2026-05-12)
**배경**: Phase D.4 / D.5 에서 file/image/img 진입 경로는 canonical input + FilePicker 로 전부 통합되고,
옛 file/media 본체의 runtime / register / config 경로도 차단됨. 이 phase 는 사용처 0건이 된 옛 본체를
실제 `git rm` 으로 삭제. shared file viewer 유틸은 `GlobalFileViewer` / `FileAttachmentDetailModal` /
`FileComponentConfigPanel` 의존성이 있어 보존.
**삭제 (git rm)**:
- 폴더 통째: `lib/registry/components/v2-file-upload/` (8 파일) · `v2-media/` (2 파일) · `image-widget/` (3 파일) · `image-display/` (6 파일)
- 본체 파일: `components/v2/V2Media.tsx` · `components/v2/config-panels/V2MediaConfigPanel.tsx` · `components/v2/config-panels/V2FileUploadConfigPanel.tsx` · `components/screen/widgets/types/ImageWidget.tsx`
- file-upload 본체 3 파일: `FileUploadComponent.tsx` · `FileUploadRenderer.tsx` · `FileUploadConfigPanel.tsx` + `config.ts` + `README.md`
**보존 (shared file viewer 유틸)**:
- `lib/registry/components/file-upload/FileViewerModal.tsx``GlobalFileViewer` / `FileAttachmentDetailModal` 의존
- `lib/registry/components/file-upload/FileManagerModal.tsx` — FileViewerModal 사용
- `lib/registry/components/file-upload/types.ts``FileInfo` / `FileUploadConfig` / `FileUploadResponse` 가 4 곳에서 사용
**정리 (잔여 참조)**:
- `file-upload/index.ts` 재작성 — FileUploadComponent / FileUploadRenderer / FileUploadConfigPanel re-export 제거.
`FileViewerModal` / `FileManagerModal` + 타입 3종 (`FileInfo` / `FileUploadConfig` / `FileUploadResponse`) 만 export 하는 shim
- `components/screen/widgets/types/index.ts``ImageWidget` import / export / `getWidgetComponentByName``case "ImageWidget"` /
`getWidgetComponentByWebType` 의 image/img/picture/photo 분기 / `WebTypeComponents``image` 매핑 모두 제거
**잔존 grep 매치 분류 (모두 운영 영향 없음)**:
- 폐기 안내 주석 / 작업 노트
- `WidgetRenderer.tsx:41``isImageWidget` 변수 — widgetType 문자열 (`image/img/picture/photo`) 매칭만. ImageWidget 본체 import 없이 wrapper `pointer-events-none` 처리, 실제 렌더는 `DynamicWebTypeRenderer` → canonical input (Phase D.4)
- `pop-components/pop-text.tsx``ImageDisplay` — POP 컴포넌트 영역의 내부 helper 함수. 폐기된 `image-display/` 폴더와 무관
**검증**:
- ✅ `git diff --check` — 0건
- ✅ `rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer"` — 0건
- ✅ `rg "componentType: \"v2-file-upload\"|file: \"v2-file-upload\"|image: \"v2-file-upload\"|img: \"v2-file-upload\""` — 0건
- ✅ `rg "EntityPicker|entity-picker|EntitySearchModal" canonical input 경로` — 0건
- ✅ `rg "FileUploadComponent|FileUploadRenderer|FileUploadConfigPanel|V2Media\b|V2MediaConfigPanel|V2FileUpload|ImageWidget|ImageDisplay"`:
- 잔존은 모두 폐기 주석 / `isImageWidget` 변수 / `pop-text` 의 ImageDisplay 내부 helper. **외부 runtime / config / register 직접 import 0건**
- ✅ `tsc --noEmit` — 삭제로 인한 `Cannot find module` 오류 0건. 잔존 3건은 모두 D.6 과 무관한 기존 module 오류 (`hierarchyColumn` / `auto-animate` / `lib/types/screen`)
**파일 통계**:
- 삭제: 13 단일 파일 + 4 폴더 (총 ~30 파일)
- 수정: 2 파일 (`file-upload/index.ts` shim 재작성, `widgets/types/index.ts` ImageWidget 정리)
### Phase A.8 — 채번 admin 페이지 + 시퀀스 관리 (★ 별도 트랙)
**배경**: VEX 는 채번을 캔버스 컴포넌트로 처리 (잘못된 구조). 팀장 요구 — 별도 admin 페이지에서 채번 규칙 + 시퀀스 일원 관리. INVYONE 에는 그 기능 없음 → 신규 작성.
@@ -198,7 +547,7 @@
- `rulesRefreshKey` state + numberingRules effect dep 추가
- 생성 후 새 ruleId 자동 선택 + rules 목록 refresh
- **canonical 위치 통일**: `autoGeneration.numberingRuleId` (옵션 밖) → `autoGeneration.options.numberingRuleId` (옵션 안)
- V2InputConfigPanel 도 옵션 안에 저장. autoGeneration.ts.generateValue 도 옵션 안 사용.
- 옛 입력 설정 패널도 옵션 안에 저장. autoGeneration.ts.generateValue 도 옵션 안 사용.
- InputComponent 의 NumberingPicker `numberingRuleId` prop 도 옵션 안에서 읽음 → 일관 ★
- 이전 옵션 밖 fallback 제거 (canonical 1안 원칙)
- ✅ Codex 검증 — 2 issue fix
@@ -210,7 +559,7 @@
### Phase A.6 — numbering API hook (완료)
- ✅ `frontend/lib/registry/components/input/numbering-picker.tsx` 신규
- V2Input.tsx:600~949, 1069~1144 의 채번 본체 추출 — useState/useRef + 3 useEffect (main / debounce / beforeFormSave) + 렌더 (readonly text vs prefix-input-suffix)
- 옛 입력 컴포넌트의 채번 본체 추출 — useState/useRef + 3 useEffect (main / debounce / beforeFormSave) + 렌더 (readonly text vs prefix-input-suffix)
- `previewNumberingCode(ruleId, formData, manualInputValue)` API 사용
- ruleId 결정 흐름: `props.numberingRuleId` 우선 → 없으면 `by-column` API → fallback `getTableColumns.detailSettings.numberingRuleId`
- `____` 템플릿: 첫 생성 시 templateRef set + 부모 value, 카테고리 변경 시 manualInputValue 유지 + 새 조합값 onChange
@@ -243,7 +592,7 @@
- ✅ Radio/Checkbox list 의 **외각 box 제거**`mode=radio||check` 시 container border/bg 없음 (자체 visual element 가 표시)
- ✅ **"복수 선택" CPSwitch 제거** (고급 설정) — brumb 의 `단일/다중` 이 진실의 원천. 단일/다중 = 저장 형태 차이 (값 cardinality), UX 토글 아님
- ✅ "최대 개수" 노출 조건 — `multi prop` 으로 분기 (config.multiple 의존 X)
- ✅ ConfigPanel "선택 방식" 옵션을 multi 따라 분기 — single: dropdown/combobox/radio/toggle, multi: dropdown/combobox/check/tag
- ✅ ConfigPanel "선택 방식" 옵션을 multi 따라 분기 — single: dropdown/combobox/radio/toggle, multi: dropdown/combobox/check/swap/tag
- ✅ ~~`displayMode` 신규 prop~~ 폐기 — 기존 `config.mode` 와 중복이라 정리. mode 만 사용
- ✅ Radio/Checkbox list — flex-col → flex-wrap 으로 변경 (디자인/운영 모드 외형 일관, 박스 사이즈에 맞게 자동 wrap)
- ✅ picker 4개 wrapper 의 `opacity-50` 제거 (시각 흐릿 해소)
@@ -299,6 +648,115 @@ const runtimeConfig =
- `templateAdapter.ts:76``[saveTemplate] payload` 일반 진단용 로그 — 유지 (low-noise)
- 그 외 [Input debug] / [Save layout debug] / [loadTemplate] / [Input render path] 등 모두 제거 완료
### Phase B.5 — option loader (2026-05-12)
**배경**: InputComponent 의 select 분기가 `componentConfig.options` 정적 배열만 처리했음.
공통코드 (`source=code`) / 사용자 카테고리 (`source=category`) / DB distinct (`source=distinct`)
/ 외부 API (`source=api`) 케이스는 옛 선택 컴포넌트의 옵션 로딩 로직에 갇혀 있어서 input
canonical 만으로는 옵션이 비어 보였음.
**변경**:
- ✅ `frontend/lib/registry/components/input/use-option-loader.ts` 신규 hook
- 입력 : `config / tableName / columnName / formData / isDesignMode`
- 처리 source : `static / code / category / distinct (=select) / api / db`
- 응답 정규화 : `Array<{value, label}>``value/code/id/valueCode` + `label/name/valueLabel`
여러 응답 shape 흡수. category 트리는 `flattenCategoryTree` 로 평탄화 (`valueCode` 우선)
- 디자인 모드 가드 : `isDesignMode === true` 면 fetch 자체 skip → static 만
- 캐시 : module-scoped `Map<url, options>` 로 같은 url 재호출 차단
- race 방지 : 요청 id (`reqIdRef`) + `cancelled` flag
- 계층 (`hierarchical + parentField`) : `parentValue``formData[parentField]` 에서
오면 `/common-codes/categories/{group}/hierarchy?parentCodeValue=...` 로 자식 코드 조회
- entity 는 참조 테이블의 value/label 컬럼을 code-name 옵션으로 로드
- 실패 시 `console.warn` + 빈 옵션 (컴포넌트가 죽지 않음)
- ✅ `InputComponent.tsx` 본체 — `useOptionLoader` 호출 + select 분기에서 `loadedOptions`
를 picker 들 (SingleSelectPicker / MultiSelectPicker / RadioPicker / CheckboxListPicker)
에 직접 전달. 정적 정규화 코드 (rawOptions.map) 제거
- ✅ FieldOption (string | object) 과 SelectOption (object) 의 union vs concrete 충돌
해소를 위해 loader 가 자체 `LoadedOption` 타입 정의해 반환
**구현한 source**: static / code / category / distinct / select / api / db (V2 legacy)
**TODO 로 남은 source / 기능**:
- 다단 cascade (계층 외에 sourceFiltering 으로 N→1 의존)
- 옵션 검색 `filters` (`OptionFilter[]`) — runtime 치환 후 query string 전달.
InvFieldConfigPanel 에 filter UI 가 아직 없어서 데이터 자체가 없음. config 가 들어오면
쉽게 확장 가능하도록 hook 내부에 자리만 잡아둠 (현재는 미적용)
- ✅ ~~swap 모드~~ (B.4.5 — 2026-05-12 완료)
**검증**:
- ✅ `git diff --check` 통과 (whitespace 0)
- ✅ `rg V2SelectRenderer | v2-input/V2InputRenderer | id: "v2-input" | id: "v2-select" |
componentType: "v2-input/select" | type: "v2-input/select"` — 외부 import 0건
(`v2-input` / `v2-select` 렌더러 폴더 자체 정의는 Phase D에서 삭제 완료)
- ✅ `npx tsc --noEmit --pretty false` 변경 파일 (InputComponent / use-option-loader)
타입 오류 0건. `DateInputComponent.tsx:214` 의 기존 옛 에러는 Phase E 폐기 예정
### Phase D.1 — v2-input/v2-select 렌더러 파일 삭제 (2026-05-12)
**배경**: Phase C.2/B.5 이후 `v2-input` / `v2-select` 는 더 이상 registry/import 경로에서 쓰이지 않음. 새 솔루션 개발 기준이므로 기존 저장 화면 호환 / DB layout 마이그레이션은 목표가 아님.
**변경**:
- ✅ `frontend/lib/registry/components/v2-input/V2InputRenderer.tsx` 삭제
- ✅ `frontend/lib/registry/components/v2-input/index.ts` 삭제
- ✅ `frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx` 삭제
- ✅ `frontend/lib/registry/components/v2-select/index.ts` 삭제
- ✅ 혼선 방지용 주석 정리 — 새 생성 경로는 `input` canonical 이며, legacy alias/fallback 은 Phase D.2 에서 제거 대상
**검증**:
- ✅ `rg components/v2-input|components/v2-select|V2InputRenderer|V2SelectRenderer` — 실제 참조 0건
- ✅ `rg id/componentType/type: "v2-input|v2-select"` — 신규 생성 literal 0건
- ✅ `git diff --check` 통과
### Phase B.4.5 — SwapPicker (2026-05-12)
**배경**: InputComponent 의 select multi 분기가 `dropdown/multi/radio/check/tag` 까지 처리하지만
`mode=swap` 은 미구현. 양쪽 리스트로 항목을 이동하는 UI 가 옛 다중 선택의 표준 패턴 중 하나라
canonical input 에 흡수.
**변경**:
- ✅ `frontend/lib/registry/components/input/select-pickers.tsx``SwapPicker` 신규 export
- props: `value: string[] / onChange / options / maxSelect / disabled / readonly / className / availableLabel / selectedLabel`
- UI: 왼쪽 (available, options 순서) ↔ 가운데 (`ChevronRight`/`ChevronLeft` 버튼) ↔ 오른쪽 (selected, value 순서)
- 항목 클릭 → 같은 패널 내 highlight 토글 (다중 선택). 이동 후 highlight 자동 해제
- 순서 정책: left→right 이동 시 selected 끝에 append, right→left 이동 시 그 항목만 제거
- options 에 없는 value 가 들어와도 selected 에 표시 (label = value fallback)
- opt.disabled / 패널 lockEdit 항목은 클릭 차단 + opacity 60%
- ✅ `frontend/lib/registry/components/input/InputComponent.tsx` — select multi 분기 `mode === "swap"` 추가
- 위치: tag → check → **swap** → dropdown/combobox (기본) 순. 기존 분기 보존
- `loadedOptions` 그대로 전달, `maxSelect` / disabled / readonly 동일하게 전달
- ✅ `frontend/components/v2/config-panels/InvFieldConfigPanel.tsx` — 고급 설정의 `선택 방식`
`swap` 노출. `multi + list` 는 패널 설명처럼 기본 `mode=check` 로 정렬
**maxSelect / readonly / disabled 처리**:
- `maxSelect`: left→right 이동 시 `selected.length + additions.length > maxSelect``slice(0, maxSelect)` 로 잘림.
selected 가 이미 한도면 right 화살표 버튼 자체 비활성 (`atLimit`).
- `readonly` / `disabled`: 단일 `lockEdit` 플래그로 통합. 항목 클릭, 화살표 버튼, 패널 hover 효과 모두 비활성.
컨테이너에 `cursor-not-allowed` 부착.
**검증**:
- ✅ `git diff --check` 통과 (whitespace 0)
- ✅ `rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer"` — 0건 유지
- ✅ `rg "V2Input|V2Select|V2InputConfigPanel|V2SelectConfigPanel"``V2SelectedItemsDetailInputConfigPanel`
substring 매치만 잔존 (unrelated 컴포넌트)
- ✅ `npx tsc --noEmit` — InputComponent / select-pickers / use-option-loader 신규 오류 0건
### Phase D.2 — v2-input/v2-select alias/fallback/schema 완전 제거 (2026-05-12)
**배경**: 목표는 InvFieldConfigPanel UI 호환이 아니라 FieldConfig / DataPort 계약 호환. V2 입력/선택 구현체를 runtime alias/fallback/schema 로 살리지 않고, 필요한 기능만 canonical `input` 에 흡수한다.
**변경**:
- ✅ `DynamicComponentRenderer.tsx` — legacy alias / V2SelectRenderer 직접 require / 입력·선택 특수 처리 제거
- ✅ `getComponentConfigPanel.tsx`, `V2PropertiesPanel.tsx` — V2 입력/선택 패널 매핑 제거
- ✅ `componentConfig.ts` — V2 입력/선택 override schema / default config 제거
- ✅ 화면/디자이너/aggregation 패널/CSS/templateMigrate 주석·분기 제거
- ✅ `InputComponent.tsx` — 옛 `rawType` fallback 분기 제거
**검증**:
- ✅ `git diff --check` 통과
- ✅ `rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles` — 0건
- ✅ `rg "components/v2-input|components/v2-select|v2-input/V2InputRenderer|v2-select/V2SelectRenderer" frontend` — 0건
- ✅ FieldConfig / DataPort 계약 축소 없음. `types/invyone-component.ts` 변경은 entity FieldRef 확장(autoFill/filter/modalColumns)만.
---
## 3. 진행 중 / 남은 작업
@@ -312,9 +770,10 @@ const runtimeConfig =
- ✅ ~~**B.2** MultiSelectPicker — multi / maxSelect~~ (완료)
- ✅ ~~**B.3** TagPicker — tags (tagbox)~~ (완료)
- ✅ ~~**B.4** radio / checkbox / toggle~~ (완료)
- **B.4.5** SwapPicker — multi + mode=swap (양쪽 list 간 이동, 큰 작업)
- **B.5** option loader — api / code / category / distinct / 계층 (apiClient 의존)
- webTypeMapping 의 multi / checkbox / radio / boolean / code / category 매핑도 점진 input
- ~~**B.4.5** SwapPicker — multi + mode=swap~~ (2026-05-12 완료 — select-pickers.tsx 에 신규)
- ~~**B.5** option loader — api / code / category / distinct / 계층~~ (2026-05-12 완료 — `use-option-loader.ts` 신규)
- webTypeMapping 의 checkbox / radio / boolean / code / category / entity 매핑 → input
- ⏳ 실제 옵션 주입 검증 — 공통코드 / 카테고리 데이터 있는 화면에서 운영 동작 확인 필요
### 디버그 (해결)
@@ -322,18 +781,35 @@ const runtimeConfig =
### Phase C — entity
- entity 검색팝업 + 다른 컬럼 auto-fill (V2Select.tsx:1007~)
- ⏳ 현재 InputComponent entity 분기는 placeholder 버튼만
- entity code-name 옵션 로딩 (`/entity/{table}/options?value=...&label=...`)
- InputComponent entity 분기 → Single/Multi select picker
- ✅ multi entity (`type="multi" + format="entity"`) 는 JSON 배열 저장 형태로 select 계열 picker 재사용
### Phase D — V2 폐기
- webTypeMapping 의 select 계열 (multi/category/entity/code/checkbox/radio/boolean) → input (B 단계 후)
- webTypeMapping 의 select 계열 (category/entity/code/checkbox/radio/boolean) → input
- ⏳ webTypeMapping 의 file/image/img → input (file 통합 후)
- `ScreenSettingModal.tsx:2052-2066` 의 옛 컴포넌트 생성 경로 → input
- DB 마이그 — `screens` 테이블의 `layout` JSON 안 componentType 변경 (사용자 승인 필요)
- `V2Input.tsx` / `V2Select.tsx` 파일 삭제
- `registerV2Components.ts` 의 v2-input / v2-select 등록 제거
- `V2InputConfigPanel.tsx` (832줄) 삭제 — 이전 세션의 폐기 보류
- `ScreenSettingModal.tsx:2052-2066` 의 옛 컴포넌트 생성 경로 → input
- DB layout JSON 마이그레이션 안 함 — 새 솔루션 개발 기준, 기존 V2 저장 데이터 보존은 목표 아님
- `frontend/lib/registry/components/v2-input/` `v2-select/` 렌더러 파일 삭제
- `registerV2Components.ts` 의 v2-input / v2-select 등록 제거
- `components/index.ts` 의 v2-input / v2-select renderer auto-register 제거
- ✅ v2-input / v2-select alias / fallback / schema 제거
- ✅ `V2Input.tsx` / `V2Select.tsx` 본체 및 `V2InputConfigPanel.tsx` / `V2SelectConfigPanel.tsx` 삭제
### Phase D.3 — V2Input / V2Select 본체 및 직접 참조 삭제
- ✅ `frontend/components/v2/V2Input.tsx` 삭제
- ✅ `frontend/components/v2/V2Select.tsx` 삭제
- ✅ `frontend/components/v2/config-panels/V2InputConfigPanel.tsx` 삭제
- ✅ `frontend/components/v2/config-panels/V2SelectConfigPanel.tsx` 삭제
- ✅ `components/v2/index.ts`, `config-panels/index.ts`, `V2ComponentRenderer.tsx`, `V2ComponentsDemo.tsx`, `DynamicConfigPanel.tsx` 직접 참조 제거
- ✅ `types/v2-components.ts` 의 옛 입력/선택 타입·type guard·legacy map 제거
- ✅ `V2SelectFilter` 의존을 canonical `OptionFilter` 로 이전
- ✅ `rg "V2Input|V2Select|V2InputConfigPanel|V2SelectConfigPanel|components/v2/V2Input|components/v2/V2Select" frontend/lib frontend/components frontend/app frontend/types`
결과는 `V2SelectedItemsDetailInputConfigPanel` substring 매치만 잔존 (별개 컴포넌트)
- ✅ `rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles` — 0건
- ✅ FieldConfig / DataPort 계약 축소 없음. 이번 변경으로 `invyone-component.ts` / `dataPort/runtime.ts` 추가 변경 없음.
### Phase E — 옛 6개 폐기
@@ -345,14 +821,14 @@ const runtimeConfig =
## 4. 위험 영역 / 주의사항
1. **V2Input / V2Select 폐기 전 고유 기능 이식 검증 필수**
- V2Input: numbering API · mask · password · slider · color picker
- V2Select: radio/check/toggle/swap mode · entity FK 검색·auto-fill · option loader (api/code/category/distinct/계층)
1. **옛 입력/선택 고유 기능 이식 검증**
- 이식 완료: numbering API, password/email/tel/url, money/number, radio/check/toggle/swap, entity code-name option, option loader(api/code/category/distinct/계층)
- 남은 항목: mask, slider, color picker, file/image/img 통합
2. **DB 마이그 (Phase D 4단계)**
- 화면 데이터는 `screens` 테이블의 `layout` JSON 안에 통째 저장 (별도 component 테이블 없음 — `screen_components` 같은 이름 X)
- JSON 안 `componentType` / `componentConfig` 변경 SQL 필요 (jsonb_set 또는 string replace)
- 운영 단계 아니라 데이터 마이그 부담 작음 — 단 UPDATE 사용자 승인 필요 (메모리)
2. **DB layout 마이그 금지**
- 새 솔루션 개발 기준이므로 기존 저장 화면 보존은 목표가 아님.
- `screens` / `templates` JSON 안 옛 componentType 을 변환하는 SQL 작성 금지.
- 정리 대상은 코드의 생성/렌더/설정/schema 경로이며, FieldConfig / DataPort 계약 호환을 우선한다.
3. **dev reload 캐시**
- webTypeMapping.ts 변경은 새로 끌어 놓는 컴포넌트만 반영
@@ -379,12 +855,15 @@ frontend/lib/utils/getComponentConfigPanel.tsx ← componentId
frontend/lib/registry/DynamicComponentRenderer.tsx ← 캔버스 렌더 dispatch (fieldType swap 제거됨)
frontend/components/screen/panels/V2PropertiesPanel.tsx ← properties 패널 라우팅
[폐기 완료]
frontend/components/v2/V2Input.tsx ← 삭제 완료
frontend/components/v2/V2Select.tsx ← 삭제 완료
frontend/components/v2/config-panels/V2InputConfigPanel.tsx ← 삭제 완료
frontend/components/v2/config-panels/V2SelectConfigPanel.tsx ← 삭제 완료
[폐기 예정]
frontend/components/v2/V2Input.tsx ← 1286줄
frontend/components/v2/V2Select.tsx ← 1350줄
frontend/lib/registry/components/{date,text,number}-input/ ← 자체 캔버스 컴포넌트 폐기
frontend/lib/registry/components/{select,checkbox,textarea}-basic/
frontend/components/v2/config-panels/V2InputConfigPanel.tsx ← 832줄 (이전 세션 폐기 보류)
[유틸 / API]
frontend/lib/utils/autoGeneration.ts ← AutoGenerationUtils.generateValue (A.5 hook 연결됨)
@@ -397,7 +876,7 @@ frontend/lib/api/numberingRule.ts ← previewNumberi
| 결정 | 이유 |
|---|---|
| Canonical = InvFieldConfigPanel + InputComponent | brumb (kind/type/format) 풍부, FieldConfig spec 정합 |
| Canonical = FieldConfig/DataPort + InputComponent | FieldConfig 가 유일한 필드 규격, DataPort 가 컴포넌트 통신 계약 |
| (가)/(다) 하이브리드 거부 → (나) 통합 추진 | canonical 1안 원칙 / 운영 단계 아님 |
| webTypeMapping → input 점진 변경 | V2Select 고유 기능 이식 후 매핑 변경 (안전) |
| native `<select>` → SingleSelectPicker | OS 기본 dropdown 통일 어려움 + V2Select 풍부 기능 흡수 |
@@ -411,31 +890,18 @@ frontend/lib/api/numberingRule.ts ← previewNumberi
### 우선순위
1. **B.4.5 — SwapPicker** (큰)
- multi + mode=swap
- 양쪽 list (선택 가능 / 선택됨) + 이동 버튼
- V2Select.tsx:530~ 참고
1. **D 후속 — file/image/img → input 통합**
- webTypeMapping 의 file/image/img 경로
- 기존 file-upload / image-widget / v2-media 기능 중 필요한 것만 canonical input 으로 흡수
2. **B.5 — option loader** (중)
- api / code / category / distinct / 계층
- V2Select 의 option 로딩 로직 이식
- apiClient 의존
2. **C 후속 — entity filter runtime** (중)
- `filters` 를 entity/category/code option query 에 runtime 적용
- field/user/static 값 치환 후 API query 전달
3. **Centity 검색팝업 + auto-fill** (큰)
- V2Select.tsx:1007~ 의 entity FK 검색
- 다른 컬럼 auto-fill (조인)
- 현재 InputComponent entity 분기 = placeholder 버튼만
3. **D남은 입력계 폐기**
- mask / slider / color picker 흡수 여부 결정
4. **DV2 폐기** (위 1~3 완료 후)
- webTypeMapping 의 select 계열 (multi/category/entity/code/checkbox/radio/boolean) → input
- webTypeMapping 의 file/image/img → input (file 통합 후)
- `ScreenSettingModal.tsx:2052-2066` 의 옛 컴포넌트 생성 경로 → input
- DB 마이그 — `templates.views` JSON 안 componentType / url 변경 (사용자 승인 필요)
- `V2Input.tsx` / `V2Select.tsx` 파일 삭제
- `registerV2Components.ts` 의 v2-input / v2-select 등록 제거
- `V2InputConfigPanel.tsx` (832줄) 삭제
5. **E — 옛 6개 폐기**
4. **E옛 6개 폐기**
- date-input / text-input / number-input / select-basic / checkbox-basic / textarea-basic 의 캔버스 컴포넌트 (`DateInputComponent.tsx` 등) 폐기
- 6 폴더 자체 삭제 (ScreenSettingModal 의 생성 경로 검증 후)
@@ -443,7 +909,7 @@ frontend/lib/api/numberingRule.ts ← previewNumberi
- ✅ defaultValue 동작 검증 완료 — form-popup 수정 모달에서 default 적용 됨
- 이전에 한 변경들은 form-popup 의 BlockRenderer hijack 으로 동작 검증이 어려웠음. 이제 가능
- ⏳ Phase B.4.5 / B.5 / C 중 어디부터 진행할지 결정 — 사용자 의도 (사진 확인 가능한 것 우선) 따라
- ⏳ D 후속 / C 후속 / E 중 어디부터 진행할지 결정 — 사용자 의도 (사진 확인 가능한 것 우선) 따라
### 핵심 사실 (새 세션이 알아야 할 것)
@@ -0,0 +1,215 @@
# Codex Handoff — Input Canonical Migration
날짜: 2026-05-12
브랜치: `gbpark-node`
주 작업 문서: `notes/gbpark/2026-05-08-input-canonical-migration.md`
이 문서는 다른 컴퓨터에서 Codex가 바로 이어받기 위한 요약이다.
---
## 1. 목표
INVYONE Studio 입력 계열을 `FieldConfig / DataPort` 계약을 유지한 채 canonical `input`으로 통합한다.
핵심 원칙:
- `FieldConfig`의 원천은 `frontend/types/invyone-component.ts`다. 설정 패널 UI가 원천이 아니다.
- `DataPort / Connection` 계약은 축소하지 않는다.
- `v2-input`, `v2-select`는 구현체, alias, fallback, schema, DB layout 마이그 대상이 아니다. 제거 대상이다.
- 기존 DB layout JSON 마이그레이션은 하지 않는다. 새 솔루션 개발 기준이다.
- `entity`는 검색 모달이 아니라 참조 테이블의 code-name 옵션 source다.
- 검색 모달이 필요하면 별도 `entity-search-input` 계열에서 처리한다.
---
## 2. 완료된 범위
### V2 input/select 제거
삭제 완료:
- `frontend/lib/registry/components/v2-input/`
- `frontend/lib/registry/components/v2-select/`
- `frontend/components/v2/V2Input.tsx`
- `frontend/components/v2/V2Select.tsx`
- `frontend/components/v2/config-panels/V2InputConfigPanel.tsx`
- `frontend/components/v2/config-panels/V2SelectConfigPanel.tsx`
정리 완료:
- renderer alias / fallback 제거
- config panel alias 제거
- schema/default 제거
- registerV2Components 등록 제거
- V2 demo / V2 renderer 직접 참조 제거
- `types/v2-components.ts`에서 V2Input/V2Select 타입 제거
검증 기준:
```bash
rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles
```
결과는 0건이어야 한다.
### canonical select/entity
추가:
- `frontend/lib/registry/components/input/use-option-loader.ts`
- `frontend/lib/registry/components/input/select-pickers.tsx``SwapPicker`
`useOptionLoader` source:
- `static`
- `code`
- `category`
- `entity`
- `distinct` / `select`
- `api`
- `db`
entity 정책:
- `/entity/{table}/options?value={valueColumn}&label={labelColumn}`
- `config.entityTable/entityValueColumn/entityLabelColumn`
- 또는 `config.ref.table/valueColumn/displayColumn`
- entity modal 재유입 금지
filter runtime:
- `OptionFilter[]`를 runtime에서 `formData` / `userContext`로 치환
- `entity`, `distinct/select`, `db` URL에 `filters=` query append
- `code`, `category`, `api`는 backend/external spec 미정으로 미적용
### canonical file/image/img
추가:
- `frontend/lib/registry/components/input/file-picker.tsx`
변경:
- `InputComponent``case "file"``FilePicker` 사용
- `file/image/img/picture/photo`는 canonical input file로 라우팅
- `webTypeMapping.ts`에서 `file/image/img` 모두 `componentType: "input"`
- `DynamicWebTypeRenderer`의 old FileUpload/ImageWidget 직접 import 제거
- `InteractiveScreenViewerDynamic` master save에서 canonical file 값 업로드 후 id/path로 치환
file 저장 정책:
- `string/string[]`: 이미 저장된 값으로 보고 유지
- `File/File[]`: `uploadFiles` 호출 후 `id ?? server_path ?? server_filename`로 치환
- mixed `(File | string)[]`: string 유지 + File 업로드 결과 합침
- 업로드 실패 시 `File` 객체가 `saveData`에 들어가지 않도록 string만 유지
old file/media 삭제 완료:
- `frontend/lib/registry/components/v2-file-upload/`
- `frontend/lib/registry/components/v2-media/`
- `frontend/lib/registry/components/image-widget/`
- `frontend/lib/registry/components/image-display/`
- `frontend/components/v2/V2Media.tsx`
- `frontend/components/v2/config-panels/V2MediaConfigPanel.tsx`
- `frontend/components/v2/config-panels/V2FileUploadConfigPanel.tsx`
- `frontend/components/screen/widgets/types/ImageWidget.tsx`
- `frontend/lib/registry/components/file-upload/FileUploadComponent.tsx`
- `frontend/lib/registry/components/file-upload/FileUploadRenderer.tsx`
- `frontend/lib/registry/components/file-upload/FileUploadConfigPanel.tsx`
- `frontend/lib/registry/components/file-upload/config.ts`
- `frontend/lib/registry/components/file-upload/README.md`
보존:
- `frontend/lib/registry/components/file-upload/FileViewerModal.tsx`
- `frontend/lib/registry/components/file-upload/FileManagerModal.tsx`
- `frontend/lib/registry/components/file-upload/types.ts`
- `frontend/lib/registry/components/file-upload/index.ts`는 shared viewer/type export shim
검증 기준:
```bash
rg "componentType: \"v2-file-upload\"|file: \"v2-file-upload\"|image: \"v2-file-upload\"|img: \"v2-file-upload\"" frontend/lib frontend/components frontend/app frontend/types
rg "FileUploadComponent|FileUploadRenderer|FileUploadConfigPanel|V2Media\\b|V2MediaConfigPanel|V2FileUpload|ImageWidget|ImageDisplay" frontend/lib frontend/components frontend/app frontend/types
```
첫 번째는 0건이어야 한다. 두 번째는 폐기 주석, `WidgetRenderer``isImageWidget` 문자열, `pop-text` 내부 helper 정도만 남아야 한다.
---
## 3. 현재 known verification
통과:
```bash
git diff --check
git diff --cached --check
rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles
rg "componentType: \"v2-file-upload\"|file: \"v2-file-upload\"|image: \"v2-file-upload\"|img: \"v2-file-upload\"" frontend/lib frontend/components frontend/app frontend/types
rg "EntityPicker|entity-picker|EntitySearchModal" frontend/lib/registry/components/input frontend/components/v2/config-panels/InvFieldConfigPanel.tsx
```
전체 `tsc`는 아직 전역 기존 오류로 실패한다. D.6 삭제로 인한 old file/media `Cannot find module`은 0건이었다.
현재 확인된 기존 module 오류:
```text
@/lib/api/hierarchyColumn
@formkit/auto-animate/react
@/lib/types/screen
```
---
## 4. 다음 작업
### Phase D.7 — canonical input mask / slider / color 흡수
목표:
- 남은 옛 입력 고유 기능 `mask`, `slider`, `color`를 canonical `input`에 흡수한다.
- `v2-input/v2-select` 복구 금지.
- old file/media 복구 금지.
- FieldConfig / DataPort 변경 금지.
구현 방향:
- `slider`
- `type="number" + format="slider"` 또는 `inputType/webType === "slider"`
- `min/max/step` 지원
- `propagate` 흐름 유지
- readonly/disabled 처리
- `color`
- `type="text" + format="color"` 또는 `inputType/webType === "color"`
- native color input + hex text input
- 값은 `#rrggbb` 문자열
- `mask`
- `InputConfig.mask?: string`
- text onChange에서 간단 mask 적용
- `#` 또는 `0` = digit, `A` = alphabet, `*` = any char, 나머지는 literal
수정 후보:
- `frontend/lib/registry/components/input/InputComponent.tsx`
- `frontend/lib/registry/components/input/types.ts`
- `frontend/components/v2/config-panels/InvFieldConfigPanel.tsx`
- `frontend/lib/utils/webTypeMapping.ts`
- `frontend/components/screen/panels/ComponentsPanel.tsx`
- `frontend/lib/registry/components/index.ts`
검증:
```bash
git diff --check
git diff --cached --check
rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles
rg "componentType: \"v2-file-upload\"|file: \"v2-file-upload\"|image: \"v2-file-upload\"|img: \"v2-file-upload\"" frontend/lib frontend/components frontend/app frontend/types
rg "EntityPicker|entity-picker|EntitySearchModal" frontend/lib/registry/components/input frontend/components/v2/config-panels/InvFieldConfigPanel.tsx
```
### Phase E — old 6 input components 삭제
D.7 이후 진행.
대상:
- `frontend/lib/registry/components/date-input/`
- `frontend/lib/registry/components/text-input/`
- `frontend/lib/registry/components/number-input/`
- `frontend/lib/registry/components/select-basic/`
- `frontend/lib/registry/components/checkbox-basic/`
- `frontend/lib/registry/components/textarea-basic/`
전제:
- `webTypeMapping`, `ScreenSettingModal`, `ComponentsPanel`, `components/index.ts`, config panel alias가 모두 canonical `input`으로 정리되어야 한다.
- `DateInputComponent.tsx` 기존 타입 오류는 삭제 시 자연 해소될 가능성이 높다.
---
## 5. 금지 사항
- `v2-input`, `v2-select` 문자열 재생성 금지
- `V2InputRenderer`, `V2SelectRenderer` 복구 금지
- `V2Input.tsx`, `V2Select.tsx` 복구 금지
- `EntityPicker` / `EntitySearchModal`을 canonical input에 넣지 말 것
- old file/media 본체 복구 금지
- DB layout JSON 변환 SQL 작성 금지
- FieldConfig / DataPort 계약 축소 금지