feat: 검색 기능 개선 및 레거시 파일 업로드 통합

- 테이블 관리 서비스에서 검색 옵션에 operator를 추가하여 정확한 일치(equals) 및 부분 일치(contains) 검색을 지원하도록 개선하였습니다.
- 파일 업로드 컴포넌트에서 레거시 file-upload 기능을 통합하여 안정적인 파일 업로드를 제공하며, V2Media와의 호환성을 강화하였습니다.
- DynamicComponentRenderer에서 파일 업로드 컴포넌트의 디버깅 로깅을 추가하여 문제 해결을 용이하게 하였습니다.
- 웹 타입 매핑에서 파일 및 이미지 타입을 레거시 file-upload로 변경하여 일관성을 유지하였습니다.
This commit is contained in:
kjs 2026-02-04 17:25:49 +09:00
parent e171f5a503
commit 7ec5a438d4
10 changed files with 957 additions and 787 deletions

View File

@ -3403,14 +3403,16 @@ export class TableManagementService {
if (options.search) { if (options.search) {
for (const [key, value] of Object.entries(options.search)) { for (const [key, value] of Object.entries(options.search)) {
// 검색값 추출 (객체 형태일 수 있음) // 검색값 및 operator 추출 (객체 형태일 수 있음)
let searchValue = value; let searchValue = value;
let operator = "contains"; // 기본값: 부분 일치
if ( if (
typeof value === "object" && typeof value === "object" &&
value !== null && value !== null &&
"value" in value "value" in value
) { ) {
searchValue = value.value; searchValue = value.value;
operator = (value as any).operator || "contains";
} }
// 빈 값이면 스킵 // 빈 값이면 스킵
@ -3482,7 +3484,19 @@ export class TableManagementService {
`🎯 Entity 조인 다중선택 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} IN (${multiValues.join(", ")}) (별칭: ${alias})` `🎯 Entity 조인 다중선택 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} IN (${multiValues.join(", ")}) (별칭: ${alias})`
); );
} }
} else if (operator === "equals") {
// 🔧 equals 연산자: 정확히 일치
whereConditions.push(
`${alias}.${joinConfig.displayColumn}::text = '${safeValue}'`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 정확히 일치 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})`
);
} else { } else {
// 기본: 부분 일치 (ILIKE)
whereConditions.push( whereConditions.push(
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'` `${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
); );
@ -3543,7 +3557,14 @@ export class TableManagementService {
`🔍 다중선택 컬럼 검색: ${key} → main.${key} IN (${multiValues.join(", ")})` `🔍 다중선택 컬럼 검색: ${key} → main.${key} IN (${multiValues.join(", ")})`
); );
} }
} else if (operator === "equals") {
// 🔧 equals 연산자: 정확히 일치
whereConditions.push(`main.${key}::text = '${safeValue}'`);
logger.info(
`🔍 정확히 일치 검색: ${key} → main.${key} = '${safeValue}'`
);
} else { } else {
// 기본: 부분 일치 (ILIKE)
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`); whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
logger.info( logger.info(
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'` `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`

View File

@ -239,7 +239,8 @@ function ScreenViewPage() {
compType?.includes("textarea") || compType?.includes("textarea") ||
compType?.includes("v2-input") || compType?.includes("v2-input") ||
compType?.includes("v2-select") || compType?.includes("v2-select") ||
compType?.includes("v2-media"); // 🆕 미디어 컴포넌트 추가 compType?.includes("v2-media") ||
compType?.includes("file-upload"); // 🆕 레거시 파일 업로드 포함
const hasColumnName = !!(comp as any).columnName; const hasColumnName = !!(comp as any).columnName;
return isInputType && hasColumnName; return isInputType && hasColumnName;
}); });

View File

@ -562,13 +562,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
try { try {
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) // 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 단, v2-media 컴포넌트의 파일 배열(objid 배열)은 포함 // 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
const masterFormData: Record<string, any> = {}; const masterFormData: Record<string, any> = {};
// v2-media 컴포넌트의 columnName 목록 수집 // 파일 업로드 컴포넌트의 columnName 목록 수집 (v2-media, file-upload 모두 포함)
const mediaColumnNames = new Set( const mediaColumnNames = new Set(
allComponents allComponents
.filter((c: any) => c.componentType === "v2-media" || c.url?.includes("v2-media")) .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.columnName || c.componentConfig?.columnName) .map((c: any) => c.columnName || c.componentConfig?.columnName)
.filter(Boolean) .filter(Boolean)
); );

View File

@ -80,7 +80,7 @@ export function ComponentsPanel({
"textarea-basic", "textarea-basic",
// V2 컴포넌트로 대체됨 // V2 컴포넌트로 대체됨
"image-widget", // → V2Media (image) "image-widget", // → V2Media (image)
"file-upload", // → V2Media (file) // "file-upload", // 🆕 레거시 컴포넌트 노출 (안정적인 파일 업로드)
"entity-search-input", // → V2Select (entity 모드) "entity-search-input", // → V2Select (entity 모드)
"autocomplete-search-input", // → V2Select (autocomplete 모드) "autocomplete-search-input", // → V2Select (autocomplete 모드)
// DataFlow 전용 (일반 화면에서 불필요) // DataFlow 전용 (일반 화면에서 불필요)

File diff suppressed because it is too large Load Diff

View File

@ -427,9 +427,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트의 columnName에 해당하는 formData 값 추출 // 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id; const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id;
// 🔍 V2Media 디버깅 // 🔍 파일 업로드 컴포넌트 디버깅
if (componentType === "v2-media") { if (componentType === "v2-media" || componentType === "file-upload") {
console.log("[DynamicComponentRenderer] v2-media:", { console.log("[DynamicComponentRenderer] 파일 업로드:", {
componentType,
componentId: component.id, componentId: component.id,
columnName: (component as any).columnName, columnName: (component as any).columnName,
configColumnName: (component as any).componentConfig?.columnName, configColumnName: (component as any).componentConfig?.columnName,

View File

@ -3,90 +3,86 @@
import React from "react"; import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2MediaDefinition } from "./index"; import { V2MediaDefinition } from "./index";
import { V2Media } from "@/components/v2/V2Media"; import FileUploadComponent from "../file-upload/FileUploadComponent";
/** /**
* V2Media * V2Media
* , , , * FileUploadComponent를
* *
*/ */
export class V2MediaRenderer extends AutoRegisteringComponentRenderer { export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2MediaDefinition; static componentDefinition = V2MediaDefinition;
render(): React.ReactElement { render(): React.ReactElement {
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; const {
component,
formData,
onFormDataChange,
isDesignMode,
isSelected,
isInteractive,
onUpdate,
...restProps
} = this.props;
// 컴포넌트 설정 추출 // 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {}; const config = component.componentConfig || component.config || {};
const columnName = component.columnName; const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName; const tableName = component.tableName || this.props.tableName;
// formData에서 현재 값 가져오기 // V1 file-upload에서 사용하는 형태로 설정 매핑
const rawValue = formData?.[columnName] ?? component.value ?? "";
// objid를 미리보기 URL로 변환하는 함수 (number/string 모두 처리)
const convertToPreviewUrl = (val: any): string => {
if (val === null || val === undefined || val === "") return "";
// number면 string으로 변환
const strVal = String(val);
// 이미 URL 형태면 그대로 반환
if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal;
// 숫자로만 이루어진 문자열이면 objid로 간주하고 미리보기 URL 생성
if (/^\d+$/.test(strVal)) {
return `/api/files/preview/${strVal}`;
}
return strVal;
};
// 배열 또는 단일 값 처리
const currentValue = Array.isArray(rawValue)
? rawValue.map(convertToPreviewUrl)
: convertToPreviewUrl(rawValue);
console.log("[V2Media] rawValue:", rawValue, "-> currentValue:", currentValue);
// 값 변경 핸들러
const handleChange = (value: any) => {
if (isInteractive && onFormDataChange && columnName) {
onFormDataChange(columnName, value);
}
};
// V1 file-upload, image-widget에서 넘어온 설정 매핑
const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType); const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType);
// maxSize: MB → bytes 변환 (V1은 bytes, V2는 MB 단위 사용) // maxSize: MB → bytes 변환
const maxSizeBytes = config.maxSize const maxSizeBytes = config.maxSize
? (config.maxSize > 1000 ? config.maxSize : config.maxSize * 1024 * 1024) ? (config.maxSize > 1000 ? config.maxSize : config.maxSize * 1024 * 1024)
: 10 * 1024 * 1024; // 기본 10MB : 10 * 1024 * 1024; // 기본 10MB
return ( // 레거시 컴포넌트 설정 형태로 변환
<V2Media const legacyComponentConfig = {
id={component.id} maxFileCount: config.multiple ? 10 : 1,
label={component.label} maxFileSize: maxSizeBytes,
required={component.required}
readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled}
value={currentValue}
onChange={handleChange}
config={{
type: mediaType,
multiple: config.multiple ?? false,
preview: config.preview ?? true,
maxSize: maxSizeBytes,
accept: config.accept || this.getDefaultAccept(mediaType), accept: config.accept || this.getDefaultAccept(mediaType),
uploadEndpoint: config.uploadEndpoint || "/files/upload", docType: config.docType || "DOCUMENT",
}} docTypeName: config.docTypeName || "일반 문서",
style={component.style} showFileList: config.showFileList ?? true,
size={component.size} dragDrop: config.dragDrop ?? true,
formData={formData} };
columnName={columnName}
tableName={tableName} // 레거시 컴포넌트 형태로 변환
{...restProps} const legacyComponent = {
...component,
id: component.id,
columnName: columnName,
tableName: tableName,
componentConfig: legacyComponentConfig,
};
// onFormDataChange 래퍼: 레거시 컴포넌트는 객체를 전달하므로 변환 필요
const handleFormDataChange = (data: any) => {
if (onFormDataChange) {
// 레거시 컴포넌트는 { [columnName]: value } 형태로 전달
// 부모는 (fieldName, value) 형태를 기대
Object.entries(data).forEach(([key, value]) => {
// __attachmentsUpdate 같은 메타 데이터는 건너뛰기
if (!key.startsWith("__")) {
onFormDataChange(key, value);
}
});
}
};
return (
<FileUploadComponent
component={legacyComponent}
componentConfig={legacyComponentConfig}
componentStyle={component.style || {}}
className=""
isInteractive={isInteractive ?? true}
isDesignMode={isDesignMode ?? false}
formData={formData || {}}
onFormDataChange={handleFormDataChange}
onUpdate={onUpdate}
/> />
); );
} }

View File

@ -475,9 +475,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
filterValue = filterValue.join("|"); filterValue = filterValue.join("|");
} }
// 🔧 filterType에 따라 operator 설정
// - "select" 유형: 정확히 일치 (equals)
// - "text" 유형: 부분 일치 (contains)
// - "date", "number": 각각 적절한 처리
let operator = "contains"; // 기본값
if (filter.filterType === "select") {
operator = "equals"; // 선택 필터는 정확히 일치
} else if (filter.filterType === "number") {
operator = "equals"; // 숫자도 정확히 일치
}
return { return {
...filter, ...filter,
value: filterValue || "", value: filterValue || "",
operator, // operator 추가
}; };
}) })
.filter((f) => { .filter((f) => {

View File

@ -107,18 +107,18 @@ export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
config: { mode: "dropdown", source: "category" }, config: { mode: "dropdown", source: "category" },
}, },
// 파일/이미지 → V2Media // 파일/이미지 → 레거시 file-upload (안정적인 파일 업로드)
file: { file: {
componentType: "v2-media", componentType: "file-upload",
config: { type: "file", multiple: false }, config: { maxFileCount: 10, accept: "*/*" },
}, },
image: { image: {
componentType: "v2-media", componentType: "file-upload",
config: { type: "image", showPreview: true }, config: { maxFileCount: 1, accept: "image/*" },
}, },
img: { img: {
componentType: "v2-media", componentType: "file-upload",
config: { type: "image", showPreview: true }, config: { maxFileCount: 1, accept: "image/*" },
}, },
// 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용) // 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용)
@ -157,9 +157,9 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
code: "v2-select", code: "v2-select",
entity: "v2-select", entity: "v2-select",
category: "v2-select", category: "v2-select",
file: "v2-media", file: "file-upload",
image: "v2-media", image: "file-upload",
img: "v2-media", img: "file-upload",
button: "button-primary", button: "button-primary",
label: "v2-input", label: "v2-input",
}; };

View File

@ -232,13 +232,27 @@ export interface V2MediaConfig {
maxSize?: number; maxSize?: number;
preview?: boolean; preview?: boolean;
uploadEndpoint?: string; uploadEndpoint?: string;
// 레거시 FileUpload 호환 설정
docType?: string;
docTypeName?: string;
showFileList?: boolean;
dragDrop?: boolean;
} }
export interface V2MediaProps extends V2BaseProps { export interface V2MediaProps extends V2BaseProps {
v2Type: "V2Media"; v2Type?: "V2Media";
config: V2MediaConfig; config?: V2MediaConfig;
value?: string | string[]; // 파일 URL 또는 배열 value?: string | string[]; // 파일 URL 또는 배열
onChange?: (value: string | string[]) => void; onChange?: (value: string | string[]) => void;
// 레거시 FileUpload 호환 props
formData?: Record<string, any>;
columnName?: string;
tableName?: string;
// 부모 컴포넌트 시그니처: (fieldName, value) 형식
onFormDataChange?: (fieldName: string, value: any) => void;
isDesignMode?: boolean;
isInteractive?: boolean;
onUpdate?: (updates: Partial<any>) => void;
} }
// ===== V2List ===== // ===== V2List =====