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

View File

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

View File

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

View File

@ -80,7 +80,7 @@ export function ComponentsPanel({
"textarea-basic",
// V2 컴포넌트로 대체됨
"image-widget", // → V2Media (image)
"file-upload", // → V2Media (file)
// "file-upload", // 🆕 레거시 컴포넌트 노출 (안정적인 파일 업로드)
"entity-search-input", // → V2Select (entity 모드)
"autocomplete-search-input", // → V2Select (autocomplete 모드)
// 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 값 추출
const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id;
// 🔍 V2Media 디버깅
if (componentType === "v2-media") {
console.log("[DynamicComponentRenderer] v2-media:", {
// 🔍 파일 업로드 컴포넌트 디버깅
if (componentType === "v2-media" || componentType === "file-upload") {
console.log("[DynamicComponentRenderer] 파일 업로드:", {
componentType,
componentId: component.id,
columnName: (component as any).columnName,
configColumnName: (component as any).componentConfig?.columnName,

View File

@ -3,90 +3,86 @@
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2MediaDefinition } from "./index";
import { V2Media } from "@/components/v2/V2Media";
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, ...restProps } = this.props;
const {
component,
formData,
onFormDataChange,
isDesignMode,
isSelected,
isInteractive,
onUpdate,
...restProps
} = this.props;
// 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {};
const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName;
// formData에서 현재 값 가져오기
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에서 넘어온 설정 매핑
// V1 file-upload에서 사용하는 형태로 설정 매핑
const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType);
// maxSize: MB → bytes 변환 (V1은 bytes, V2는 MB 단위 사용)
// 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,
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 (
<V2Media
id={component.id}
label={component.label}
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),
uploadEndpoint: config.uploadEndpoint || "/files/upload",
}}
style={component.style}
size={component.size}
formData={formData}
columnName={columnName}
tableName={tableName}
{...restProps}
<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("|");
}
// 🔧 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 {
...filter,
value: filterValue || "",
operator, // operator 추가
};
})
.filter((f) => {

View File

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

View File

@ -232,13 +232,27 @@ export interface V2MediaConfig {
maxSize?: number;
preview?: boolean;
uploadEndpoint?: string;
// 레거시 FileUpload 호환 설정
docType?: string;
docTypeName?: string;
showFileList?: boolean;
dragDrop?: boolean;
}
export interface V2MediaProps extends V2BaseProps {
v2Type: "V2Media";
config: V2MediaConfig;
v2Type?: "V2Media";
config?: V2MediaConfig;
value?: string | string[]; // 파일 URL 또는 배열
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 =====