From 219f7724e7d11bc27bdd9f8c794ad90ab613d770 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 10 Feb 2026 11:38:02 +0900 Subject: [PATCH] feat: Enhance MasterDetailExcelService with table alias for JOIN operations - Added a new property `tableAlias` to distinguish between master ("m") and detail ("d") tables during JOIN operations. - Updated the SELECT clause to include the appropriate table alias for master and detail tables. - Improved the entity join clause construction to utilize the new table alias, ensuring clarity in SQL queries. --- .../src/services/masterDetailExcelService.ts | 7 +- .../src/services/tableCategoryValueService.ts | 61 ++++++--- frontend/components/common/ScreenModal.tsx | 48 ++++++- frontend/components/v2/V2Input.tsx | 129 +++++++++++++++--- .../components/v2-select/V2SelectRenderer.tsx | 4 +- frontend/lib/utils/buttonActions.ts | 19 +++ 6 files changed, 226 insertions(+), 42 deletions(-) diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts index 87d56694..a3eecb61 100644 --- a/backend-node/src/services/masterDetailExcelService.ts +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -310,6 +310,7 @@ class MasterDetailExcelService { sourceColumn: string; alias: string; displayColumn: string; + tableAlias: string; // "m" (마스터) 또는 "d" (디테일) - JOIN 시 소스 테이블 구분 }> = []; // SELECT 절 구성 @@ -332,6 +333,7 @@ class MasterDetailExcelService { sourceColumn: fkColumn.sourceColumn, alias, displayColumn, + tableAlias: "m", // 마스터 테이블에서 조인 }); selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); } else { @@ -360,6 +362,7 @@ class MasterDetailExcelService { sourceColumn: fkColumn.sourceColumn, alias, displayColumn, + tableAlias: "d", // 디테일 테이블에서 조인 }); selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); } else { @@ -373,9 +376,9 @@ class MasterDetailExcelService { const selectClause = selectParts.join(", "); - // 엔티티 조인 절 구성 + // 엔티티 조인 절 구성 (마스터/디테일 테이블 alias 구분) const entityJoinClauses = entityJoins.map(ej => - `LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` + `LEFT JOIN "${ej.refTable}" ${ej.alias} ON ${ej.tableAlias}."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` ).join("\n "); // WHERE 절 구성 diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 2eb35f64..dd2f73a9 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1371,39 +1371,66 @@ class TableCategoryValueService { const pool = getPool(); - // 동적으로 파라미터 플레이스홀더 생성 - const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", "); + const n = valueCodes.length; + + // 첫 번째 쿼리용 플레이스홀더: $1 ~ $n + const placeholders1 = valueCodes.map((_, i) => `$${i + 1}`).join(", "); let query: string; let params: any[]; if (companyCode === "*") { - // 최고 관리자: 모든 카테고리 값 조회 + // 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합) + // 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n + const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", "); query = ` - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders}) - AND is_active = true + SELECT value_code, value_label FROM ( + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders1}) + AND is_active = true + UNION ALL + SELECT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders2}) + AND is_active = true + ) combined `; - params = valueCodes; + params = [...valueCodes, ...valueCodes]; } else { - // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 + // 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회 + // 첫 번째: $1~$n (valueCodes), $n+1 (companyCode) + // 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode) + const companyIdx1 = n + 1; + const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", "); + const companyIdx2 = 2 * n + 2; + query = ` - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders}) - AND is_active = true - AND (company_code = $${valueCodes.length + 1} OR company_code = '*') + SELECT value_code, value_label FROM ( + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders1}) + AND is_active = true + AND (company_code = $${companyIdx1} OR company_code = '*') + UNION ALL + SELECT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders2}) + AND is_active = true + AND (company_code = $${companyIdx2} OR company_code = '*') + ) combined `; - params = [...valueCodes, companyCode]; + params = [...valueCodes, companyCode, ...valueCodes, companyCode]; } const result = await pool.query(query, params); - // { [code]: label } 형태로 변환 + // { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선) const labels: Record = {}; for (const row of result.rows) { - labels[row.value_code] = row.value_label; + if (!labels[row.value_code]) { + labels[row.value_code] = row.value_label; + } } logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode }); diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 0add43d6..c2d8bcbc 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -172,6 +172,7 @@ export const ScreenModal: React.FC = ({ className }) => { selectedData: eventSelectedData, selectedIds, isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함) + fieldMappings, // 🆕 필드 매핑 정보 (명시적 매핑이 있으면 모든 매핑된 필드 전달) } = event.detail; // 🆕 모달 열린 시간 기록 @@ -267,6 +268,17 @@ export const ScreenModal: React.FC = ({ className }) => { parentData.company_code = rawParentData.company_code; } + // 🆕 명시적 필드 매핑이 있으면 매핑된 타겟 필드를 모두 보존 + // (버튼 설정에서 fieldMappings로 지정한 필드는 link 필드가 아니어도 전달) + const mappedTargetFields = new Set(); + if (fieldMappings && Array.isArray(fieldMappings)) { + for (const mapping of fieldMappings) { + if (mapping.targetField) { + mappedTargetFields.add(mapping.targetField); + } + } + } + // parentDataMapping에 정의된 필드만 전달 for (const mapping of parentDataMapping) { const sourceValue = rawParentData[mapping.sourceColumn]; @@ -275,8 +287,17 @@ export const ScreenModal: React.FC = ({ className }) => { } } - // parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴) - if (parentDataMapping.length === 0) { + // 🆕 명시적 필드 매핑이 있으면 해당 필드를 모두 전달 + if (mappedTargetFields.size > 0) { + for (const [key, value] of Object.entries(rawParentData)) { + if (mappedTargetFields.has(key) && value !== undefined && value !== null) { + parentData[key] = value; + } + } + } + + // parentDataMapping이 비어있고 명시적 필드 매핑도 없으면 연결 필드 자동 감지 + if (parentDataMapping.length === 0 && mappedTargetFields.size === 0) { const linkFieldPatterns = ["_code", "_id"]; const excludeFields = [ "id", @@ -293,6 +314,29 @@ export const ScreenModal: React.FC = ({ className }) => { if (value === undefined || value === null) continue; // 연결 필드 패턴 확인 + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); + if (isLinkField) { + parentData[key] = value; + } + } + } else if (parentDataMapping.length === 0 && mappedTargetFields.size > 0) { + // 🆕 명시적 매핑이 있어도 연결 필드(_code, _id)는 추가로 전달 + const linkFieldPatterns = ["_code", "_id"]; + const excludeFields = [ + "id", + "company_code", + "created_date", + "updated_date", + "created_at", + "updated_at", + "writer", + ]; + + for (const [key, value] of Object.entries(rawParentData)) { + if (excludeFields.includes(key)) continue; + if (parentData[key] !== undefined) continue; // 이미 매핑된 필드는 스킵 + if (value === undefined || value === null) continue; + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); if (isLinkField) { parentData[key] = value; diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index a284f26e..d8457adb 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -23,15 +23,26 @@ import { AutoGenerationConfig } from "@/types/screen"; import { previewNumberingCode } from "@/lib/api/numberingRule"; // 형식별 입력 마스크 및 검증 패턴 -const FORMAT_PATTERNS: Record = { - none: { pattern: /.*/, placeholder: "" }, - email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com" }, - tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678" }, - url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com" }, - currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000" }, - biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890" }, +const FORMAT_PATTERNS: Record = { + none: { pattern: /.*/, placeholder: "", errorMessage: "" }, + email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com", errorMessage: "올바른 이메일 형식이 아닙니다" }, + tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678", errorMessage: "올바른 전화번호 형식이 아닙니다" }, + url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com", errorMessage: "올바른 URL 형식이 아닙니다 (https://로 시작)" }, + currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000", errorMessage: "숫자만 입력 가능합니다" }, + biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890", errorMessage: "올바른 사업자번호 형식이 아닙니다" }, }; +// 형식 검증 함수 (외부에서도 사용 가능) +export function validateInputFormat(value: string, format: V2InputFormat): { isValid: boolean; errorMessage: string } { + if (!value || value.trim() === "" || format === "none") { + return { isValid: true, errorMessage: "" }; + } + const formatConfig = FORMAT_PATTERNS[format]; + if (!formatConfig) return { isValid: true, errorMessage: "" }; + const isValid = formatConfig.pattern.test(value); + return { isValid, errorMessage: isValid ? "" : formatConfig.errorMessage }; +} + // 통화 형식 변환 function formatCurrency(value: string | number): string { const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value; @@ -70,8 +81,13 @@ const TextInput = forwardRef< readonly?: boolean; disabled?: boolean; className?: string; + columnName?: string; } ->(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => { +>(({ value, onChange, format = "none", placeholder, readonly, disabled, className, columnName }, ref) => { + // 검증 상태 + const [hasBlurred, setHasBlurred] = useState(false); + const [validationError, setValidationError] = useState(""); + // 형식에 따른 값 포맷팅 const formatValue = useCallback( (val: string): string => { @@ -104,29 +120,101 @@ const TextInput = forwardRef< newValue = formatTel(newValue); } + // 입력 중 에러 표시 해제 (입력 중에는 관대하게) + if (hasBlurred && validationError) { + const { isValid } = validateInputFormat(newValue, format); + if (isValid) { + setValidationError(""); + } + } + onChange?.(newValue); }, - [format, onChange], + [format, onChange, hasBlurred, validationError], ); + // blur 시 형식 검증 + const handleBlur = useCallback(() => { + setHasBlurred(true); + const currentValue = value !== undefined && value !== null ? String(value) : ""; + if (currentValue && format !== "none") { + const { isValid, errorMessage } = validateInputFormat(currentValue, format); + setValidationError(isValid ? "" : errorMessage); + } else { + setValidationError(""); + } + }, [value, format]); + + // 값 변경 시 검증 상태 업데이트 + useEffect(() => { + if (hasBlurred) { + const currentValue = value !== undefined && value !== null ? String(value) : ""; + if (currentValue && format !== "none") { + const { isValid, errorMessage } = validateInputFormat(currentValue, format); + setValidationError(isValid ? "" : errorMessage); + } else { + setValidationError(""); + } + } + }, [value, format, hasBlurred]); + + // 글로벌 폼 검증 이벤트 리스너 (저장 시 호출) + useEffect(() => { + if (format === "none" || !columnName) return; + + const handleValidateForm = (event: CustomEvent) => { + const currentValue = value !== undefined && value !== null ? String(value) : ""; + if (currentValue) { + const { isValid, errorMessage } = validateInputFormat(currentValue, format); + if (!isValid) { + setHasBlurred(true); + setValidationError(errorMessage); + // 검증 결과를 이벤트에 기록 + if (event.detail?.errors) { + event.detail.errors.push({ + columnName, + message: errorMessage, + }); + } + } + } + }; + + window.addEventListener("validateFormInputs", handleValidateForm as EventListener); + return () => { + window.removeEventListener("validateFormInputs", handleValidateForm as EventListener); + }; + }, [format, value, columnName]); + const displayValue = useMemo(() => { if (value === undefined || value === null) return ""; return formatValue(String(value)); }, [value, formatValue]); const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder; + const hasError = hasBlurred && !!validationError; return ( - +
+ + {hasError && ( +

{validationError}

+ )} +
); }); TextInput.displayName = "TextInput"; @@ -678,6 +766,7 @@ export const V2Input = forwardRef((props, ref) => placeholder={config.placeholder} readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)} disabled={disabled} + columnName={columnName} /> ); @@ -835,9 +924,11 @@ export const V2Input = forwardRef((props, ref) => setAutoGeneratedValue(null); onChange?.(v); }} + format={config.format} placeholder={config.placeholder} readonly={readonly} disabled={disabled} + columnName={columnName} /> ); } diff --git a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx index be5a6c84..5ab010f2 100644 --- a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx +++ b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx @@ -121,8 +121,8 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer { onChange={handleChange} config={{ mode: config.mode || "dropdown", - // 🔧 카테고리 타입이면 source를 "category"로 설정 - source: config.source || (isCategoryType ? "category" : "distinct"), + // 🔧 카테고리 타입이면 source를 무조건 "category"로 강제 (테이블 타입 관리 설정 우선) + source: isCategoryType ? "category" : (config.source || "distinct"), multiple: config.multiple || false, searchable: config.searchable ?? true, placeholder: config.placeholder || "선택하세요", diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 71a23472..e1abcb25 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -541,6 +541,23 @@ export class ButtonActionExecutor { return false; } + // ✅ 입력 형식 검증 (이메일, 전화번호, URL 등) + const formatValidationDetail = { errors: [] as Array<{ columnName: string; message: string }> }; + window.dispatchEvent( + new CustomEvent("validateFormInputs", { + detail: formatValidationDetail, + }), + ); + // 약간의 대기 (이벤트 핸들러가 동기적으로 실행되지만 안전을 위해) + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (formatValidationDetail.errors.length > 0) { + const errorMessages = formatValidationDetail.errors.map((e) => e.message); + console.log("❌ [handleSave] 입력 형식 검증 실패:", formatValidationDetail.errors); + toast.error(`입력 형식을 확인해주세요: ${errorMessages.join(", ")}`); + return false; + } + // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 if (onSave) { try { @@ -3144,6 +3161,8 @@ export class ButtonActionExecutor { editData: useAsEditData && isPassDataMode ? parentData : undefined, splitPanelParentData: isPassDataMode ? parentData : undefined, urlParams: dataSourceId ? { dataSourceId } : undefined, + // 🆕 필드 매핑 정보 전달 - ScreenModal에서 매핑된 필드를 필터링하지 않도록 + fieldMappings: config.fieldMappings, }, });