Consolidate canonical input migration
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m17s
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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는 V2Input의 textarea 모드로 대체
|
||||
// 필요시 별도 구현
|
||||
// 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,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
@@ -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";
|
||||
|
||||
|
||||
@@ -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는 V2Input의 textarea 모드로 대체 가능
|
||||
// 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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+1
-3
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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), 매핑 제거
|
||||
};
|
||||
|
||||
// ===== 조건부 레이어 시스템 =====
|
||||
|
||||
Reference in New Issue
Block a user