diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 7e1108c3..43b698d2 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -1044,6 +1044,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2371,6 +2372,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3474,6 +3476,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3710,6 +3713,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3927,6 +3931,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4453,6 +4458,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5663,6 +5669,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7425,6 +7432,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8394,7 +8402,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9283,6 +9290,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10133,7 +10141,6 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -10942,6 +10949,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11047,6 +11055,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index f5cbc91a..a8f99b36 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -225,12 +225,12 @@ router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const { ruleId } = req.params; - const { formData } = req.body; // 폼 데이터 (날짜 컬럼 기준 생성 시 사용) + const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드 - logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData }); + logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData, userInputCode }); try { - const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData); + const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode); logger.info("코드 할당 성공", { ruleId, allocatedCode }); return res.json({ success: true, data: { generatedCode: allocatedCode } }); } catch (error: any) { diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index abdfd739..0bdec037 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -886,8 +886,9 @@ class NumberingRuleService { .sort((a: any, b: any) => a.order - b.order) .map((part: any) => { if (part.generationMethod === "manual") { - // 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력) - return part.manualConfig?.placeholder || "____"; + // 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리) + // placeholder 텍스트는 프론트엔드에서 별도로 표시 + return "____"; } const autoConfig = part.autoConfig || {}; @@ -1014,11 +1015,13 @@ class NumberingRuleService { * @param ruleId 채번 규칙 ID * @param companyCode 회사 코드 * @param formData 폼 데이터 (날짜 컬럼 기준 생성 시 사용) + * @param userInputCode 사용자가 편집한 최종 코드 (수동 입력 부분 추출용) */ async allocateCode( ruleId: string, companyCode: string, - formData?: Record + formData?: Record, + userInputCode?: string ): Promise { const pool = getPool(); const client = await pool.connect(); @@ -1029,11 +1032,77 @@ class NumberingRuleService { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); + // 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출 + const manualParts = rule.parts.filter((p: any) => p.generationMethod === "manual"); + let extractedManualValues: string[] = []; + + if (manualParts.length > 0 && userInputCode) { + // 프리뷰 코드를 생성해서 ____ 위치 파악 + const previewParts = rule.parts + .sort((a: any, b: any) => a.order - b.order) + .map((part: any) => { + if (part.generationMethod === "manual") { + return "____"; + } + const autoConfig = part.autoConfig || {}; + switch (part.partType) { + case "sequence": { + const length = autoConfig.sequenceLength || 3; + return "X".repeat(length); // 순번 자리 표시 + } + case "text": + return autoConfig.textValue || ""; + case "date": + return "DATEPART"; // 날짜 자리 표시 + default: + return ""; + } + }); + + const separator = rule.separator || ""; + const previewTemplate = previewParts.join(separator); + + // 사용자 입력 코드에서 수동 입력 부분 추출 + // 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출 + const templateParts = previewTemplate.split("____"); + if (templateParts.length > 1) { + let remainingCode = userInputCode; + for (let i = 0; i < templateParts.length - 1; i++) { + const prefix = templateParts[i]; + const suffix = templateParts[i + 1]; + + // prefix 이후 부분 추출 + if (prefix && remainingCode.startsWith(prefix)) { + remainingCode = remainingCode.slice(prefix.length); + } + + // suffix 이전까지가 수동 입력 값 + if (suffix) { + // suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기 + const suffixStart = suffix.replace(/X+|DATEPART/g, ""); + const manualEndIndex = suffixStart ? remainingCode.indexOf(suffixStart) : remainingCode.length; + if (manualEndIndex > 0) { + extractedManualValues.push(remainingCode.slice(0, manualEndIndex)); + remainingCode = remainingCode.slice(manualEndIndex); + } + } else { + extractedManualValues.push(remainingCode); + } + } + } + + logger.info(`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`); + } + + let manualPartIndex = 0; const parts = rule.parts .sort((a: any, b: any) => a.order - b.order) .map((part: any) => { if (part.generationMethod === "manual") { - return part.manualConfig?.value || ""; + // 추출된 수동 입력 값 사용, 없으면 기본값 사용 + const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || ""; + manualPartIndex++; + return manualValue; } const autoConfig = part.autoConfig || {}; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 09a9691d..6e8d0b7b 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1461,6 +1461,40 @@ export class TableManagementService { }); } + // 🔧 파이프로 구분된 문자열 처리 (객체에서 추출한 actualValue도 처리) + if (typeof actualValue === "string" && actualValue.includes("|")) { + const columnInfo = await this.getColumnWebTypeInfo( + tableName, + columnName + ); + + // 날짜 타입이면 날짜 범위로 처리 + if ( + columnInfo && + (columnInfo.webType === "date" || columnInfo.webType === "datetime") + ) { + return this.buildDateRangeCondition(columnName, actualValue, paramIndex); + } + + // 그 외 타입이면 다중선택(IN 조건)으로 처리 + const multiValues = actualValue + .split("|") + .filter((v: string) => v.trim() !== ""); + if (multiValues.length > 0) { + const placeholders = multiValues + .map((_: string, idx: number) => `$${paramIndex + idx}`) + .join(", "); + logger.info( + `🔍 다중선택 필터 적용 (객체에서 추출): ${columnName} IN (${multiValues.join(", ")})` + ); + return { + whereClause: `${columnName}::text IN (${placeholders})`, + values: multiValues, + paramCount: multiValues.length, + }; + } + } + // "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음 if ( actualValue === "__ALL__" || @@ -3369,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"; } // 빈 값이면 스킵 @@ -3428,15 +3464,49 @@ export class TableManagementService { // 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색 const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`; const alias = aliasMap.get(aliasKey); - whereConditions.push( - `${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'` - ); - entitySearchColumns.push( - `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` - ); - logger.info( - `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})` - ); + + // 🔧 파이프로 구분된 다중 선택값 처리 + if (safeValue.includes("|")) { + const multiValues = safeValue + .split("|") + .filter((v: string) => v.trim() !== ""); + if (multiValues.length > 0) { + const inClause = multiValues + .map((v: string) => `'${v}'`) + .join(", "); + whereConditions.push( + `${alias}.${joinConfig.displayColumn}::text IN (${inClause})` + ); + entitySearchColumns.push( + `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` + ); + logger.info( + `🎯 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}%'` + ); + entitySearchColumns.push( + `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` + ); + logger.info( + `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})` + ); + } } else if (key === "writer_dept_code") { // writer_dept_code: user_info.dept_code에서 검색 const userAliasKey = Array.from(aliasMap.keys()).find((k) => @@ -3473,10 +3543,33 @@ export class TableManagementService { } } else { // 일반 컬럼인 경우: 메인 테이블에서 검색 - whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`); - logger.info( - `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'` - ); + // 🔧 파이프로 구분된 다중 선택값 처리 + if (safeValue.includes("|")) { + const multiValues = safeValue + .split("|") + .filter((v: string) => v.trim() !== ""); + if (multiValues.length > 0) { + const inClause = multiValues + .map((v: string) => `'${v}'`) + .join(", "); + whereConditions.push(`main.${key}::text IN (${inClause})`); + logger.info( + `🔍 다중선택 컬럼 검색: ${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}%'` + ); + } } } } diff --git a/docker-compose.frontend.win.yml b/docker-compose.frontend.win.yml index f81e2287..79589463 100644 --- a/docker-compose.frontend.win.yml +++ b/docker-compose.frontend.win.yml @@ -12,6 +12,13 @@ services: environment: - NEXT_PUBLIC_API_URL=http://localhost:8080/api - WATCHPACK_POLLING=true + - NODE_OPTIONS=--max-old-space-size=4096 + deploy: + resources: + limits: + memory: 6G + reservations: + memory: 2G volumes: - ./frontend:/app - /app/node_modules diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 0ce2bae5..14230b14 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -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; }); diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 38aebadc..2f6ae80f 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -127,6 +127,24 @@ export const ScreenModal: React.FC = ({ className }) => { // 모달이 열린 시간 추적 (저장 성공 이벤트 무시용) const modalOpenedAtRef = React.useRef(0); + // 🆕 채번 필드 수동 입력 값 변경 이벤트 리스너 + useEffect(() => { + const handleNumberingValueChanged = (event: CustomEvent) => { + const { columnName, value } = event.detail; + if (columnName && modalState.isOpen) { + setFormData((prev) => ({ + ...prev, + [columnName]: value, + })); + } + }; + + window.addEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener); + return () => { + window.removeEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener); + }; + }, [modalState.isOpen]); + // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenModal = (event: CustomEvent) => { @@ -140,6 +158,7 @@ export const ScreenModal: React.FC = ({ className }) => { splitPanelParentData, selectedData: eventSelectedData, selectedIds, + isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함) } = event.detail; // 🆕 모달 열린 시간 기록 @@ -163,7 +182,8 @@ export const ScreenModal: React.FC = ({ className }) => { } // 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드) - if (editData) { + // 🔧 단, isCreateMode가 true이면 (복사 모드) originalData를 설정하지 않음 → 채번 생성 가능 + if (editData && !isCreateMode) { // 🆕 배열인 경우 두 가지 데이터를 설정: // 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등) // 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등) @@ -177,6 +197,17 @@ export const ScreenModal: React.FC = ({ className }) => { setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장 setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) } + } else if (editData && isCreateMode) { + // 🆕 복사 모드: formData만 설정하고 originalData는 null로 유지 (채번 생성 가능) + if (Array.isArray(editData)) { + const firstRecord = editData[0] || {}; + setFormData(firstRecord); + setSelectedData(editData); + } else { + setFormData(editData); + setSelectedData([editData]); + } + setOriginalData(null); // 🔧 복사 모드에서는 originalData를 null로 설정 } else { // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함 diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 03d43b82..3dccd0db 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -777,12 +777,14 @@ export const EditModal: React.FC = ({ className }) => { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { - console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); - const allocateResult = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const userInputCode = dataToSave[fieldName] as string; + console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}, 사용자입력: ${userInputCode}`); + const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData); if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; - console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]} → ${newCode}`); + console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${userInputCode} → ${newCode}`); dataToSave[fieldName] = newCode; } else { console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error); diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index d8e26377..73e6819d 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -562,13 +562,18 @@ export const InteractiveScreenViewerDynamic: React.FC = {}; - // 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) ); diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index b822aeee..6ea347c2 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -834,8 +834,10 @@ export const ButtonConfigPanel: React.FC = ({ {/* 이벤트 버스 */} 이벤트 발송 - {/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김 + {/* 복사 */} 복사 (품목코드 초기화) + + {/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김 연관 데이터 버튼 모달 열기 (deprecated) 데이터 전달 + 모달 열기 테이블 이력 보기 diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 8f055bc3..9464a204 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -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 전용 (일반 화면에서 불필요) diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx index 606d1730..d802baa7 100644 --- a/frontend/components/unified/UnifiedRepeater.tsx +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -700,9 +700,10 @@ export const UnifiedRepeater: React.FC = ({ ); // 🆕 채번 API 호출 (비동기) - const generateNumberingCode = useCallback(async (ruleId: string): Promise => { + // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 + const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { try { - const result = await allocateNumberingCode(ruleId); + const result = await allocateNumberingCode(ruleId, userInputCode, formData); if (result.success && result.data?.generatedCode) { return result.data.generatedCode; } @@ -831,7 +832,8 @@ export const UnifiedRepeater: React.FC = ({ if (match) { const ruleId = match[1]; try { - const result = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const result = await allocateNumberingCode(ruleId, undefined, newRow); if (result.success && result.data?.generatedCode) { newRow[key] = result.data.generatedCode; } else { diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index ac4bc33c..27aa73d5 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -625,6 +625,40 @@ export const V2Input = forwardRef((props, ref) => // eslint-disable-next-line react-hooks/exhaustive-deps }, [tableName, columnName, isEditMode, categoryValuesForNumbering]); + // 🆕 beforeFormSave 이벤트 리스너 - 저장 직전에 현재 조합된 값을 formData에 주입 + useEffect(() => { + const inputType = propsInputType || config.inputType || config.type || "text"; + if (inputType !== "numbering" || !columnName) return; + + const handleBeforeFormSave = (event: CustomEvent) => { + const template = numberingTemplateRef.current; + if (!template || !template.includes("____")) return; + + // 템플릿에서 prefix와 suffix 추출 + const templateParts = template.split("____"); + const templatePrefix = templateParts[0] || ""; + const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : ""; + + // 현재 조합된 값 생성 + const currentValue = templatePrefix + manualInputValue + templateSuffix; + + // formData에 직접 주입 + if (event.detail?.formData && columnName) { + event.detail.formData[columnName] = currentValue; + console.log("🔧 [V2Input] beforeFormSave에서 채번 값 주입:", { + columnName, + manualInputValue, + currentValue, + }); + } + }; + + window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); + return () => { + window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener); + }; + }, [columnName, manualInputValue, propsInputType, config.inputType, config.type]); + // 실제 표시할 값 (자동생성 값 또는 props value) const displayValue = autoGeneratedValue ?? value; @@ -769,7 +803,19 @@ export const V2Input = forwardRef((props, ref) => const newValue = templatePrefix + newUserInput + templateSuffix; userEditedNumberingRef.current = true; setAutoGeneratedValue(newValue); + + // 모든 방법으로 formData 업데이트 시도 onChange?.(newValue); + if (onFormDataChange && columnName) { + onFormDataChange(columnName, newValue); + } + + // 커스텀 이벤트로도 전달 (최후의 보루) + if (typeof window !== "undefined" && columnName) { + window.dispatchEvent(new CustomEvent("numberingValueChanged", { + detail: { columnName, value: newValue } + })); + } }} placeholder="입력" className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none" diff --git a/frontend/components/v2/V2Media.tsx b/frontend/components/v2/V2Media.tsx index 43a2c8b2..7321808f 100644 --- a/frontend/components/v2/V2Media.tsx +++ b/frontend/components/v2/V2Media.tsx @@ -3,650 +3,79 @@ /** * 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 } from "lucide-react"; +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"; /** - * 파일 크기 포맷팅 + * 파일 아이콘 매핑 */ -function formatFileSize(bytes: number): string { - if (bytes === 0) return "0 Bytes"; - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; -} +const getFileIcon = (extension: string) => { + const ext = extension.toLowerCase().replace(".", ""); -/** - * 파일 타입 아이콘 가져오기 - */ -function getFileIcon(type: string) { - if (type.startsWith("image/")) return ImageIcon; - if (type.startsWith("video/")) return Video; - if (type.startsWith("audio/")) return Music; - return File; -} - -/** - * 파일 업로드 컴포넌트 - */ -const FileUploader = forwardRef void; - multiple?: boolean; - accept?: string; - maxSize?: number; - disabled?: boolean; - uploadEndpoint?: string; - className?: string; -}>(({ - value, - onChange, - multiple = false, - accept = "*", - maxSize = 10485760, // 10MB - disabled, - uploadEndpoint = "/files/upload", - className -}, ref) => { - const inputRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const [error, setError] = useState(null); - // 업로드 직후 미리보기를 위한 로컬 상태 - const [localPreviewUrls, setLocalPreviewUrls] = useState([]); - - // objid를 미리보기 URL로 변환 - const toPreviewUrl = (val: any): string => { - if (!val) return ""; - const strVal = String(val); - if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal; - if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`; - return strVal; - }; - - // value를 URL 형태의 files 배열로 변환 - const rawFiles = Array.isArray(value) ? value : value ? [value] : []; - const filesFromValue = rawFiles.map(toPreviewUrl).filter(Boolean); - - console.log("[FileUploader] value:", value, "rawFiles:", rawFiles, "filesFromValue:", filesFromValue, "localPreviewUrls:", localPreviewUrls); - - // value가 변경되면 로컬 상태 초기화 - useEffect(() => { - if (filesFromValue.length > 0) { - setLocalPreviewUrls([]); - } - }, [filesFromValue.length]); - - // 최종 files: value에서 온 파일 + 로컬 미리보기 (중복 제거) - const files = filesFromValue.length > 0 ? filesFromValue : localPreviewUrls; - - console.log("[FileUploader] final files:", files); - - // 파일 선택 핸들러 - const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => { - if (!selectedFiles || selectedFiles.length === 0) return; - - setError(null); - const fileArray = Array.from(selectedFiles); - - // 크기 검증 - for (const file of fileArray) { - if (file.size > maxSize) { - setError(`파일 크기가 ${formatFileSize(maxSize)}를 초과합니다: ${file.name}`); - return; - } - } - - setIsUploading(true); - - try { - const uploadedUrls: string[] = []; - - for (const file of fileArray) { - const formData = new FormData(); - formData.append("files", file); - - const response = await apiClient.post(uploadEndpoint, formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - - const data = response.data; - console.log("[FileUploader] 업로드 응답:", data); - // 백엔드 응답: { success: true, files: [{ filePath, objid, ... }] } - if (data.success && data.files && data.files.length > 0) { - const uploadedFile = data.files[0]; - const objid = String(uploadedFile.objid); - uploadedUrls.push(objid); - // 즉시 미리보기를 위해 로컬 상태에 URL 저장 - const previewUrl = `/api/files/preview/${objid}`; - setLocalPreviewUrls(prev => multiple ? [...prev, previewUrl] : [previewUrl]); - } else if (data.objid) { - const objid = String(data.objid); - uploadedUrls.push(objid); - const previewUrl = `/api/files/preview/${objid}`; - setLocalPreviewUrls(prev => multiple ? [...prev, previewUrl] : [previewUrl]); - } else if (data.url) { - uploadedUrls.push(data.url); - setLocalPreviewUrls(prev => multiple ? [...prev, data.url] : [data.url]); - } else if (data.filePath) { - uploadedUrls.push(data.filePath); - setLocalPreviewUrls(prev => multiple ? [...prev, data.filePath] : [data.filePath]); - } - } - - if (multiple) { - const newValue = [...filesFromValue, ...uploadedUrls]; - console.log("[FileUploader] onChange called with:", newValue); - onChange?.(newValue); - } else { - const newValue = uploadedUrls[0] || ""; - console.log("[FileUploader] onChange called with:", newValue); - onChange?.(newValue); - } - } catch (err) { - setError(err instanceof Error ? err.message : "업로드 중 오류가 발생했습니다"); - } finally { - setIsUploading(false); - } - }, [filesFromValue, multiple, maxSize, uploadEndpoint, onChange]); - - // 드래그 앤 드롭 핸들러 - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - handleFileSelect(e.dataTransfer.files); - }, [handleFileSelect]); - - // 파일 삭제 핸들러 - const handleRemove = useCallback((index: number) => { - // 로컬 미리보기도 삭제 - setLocalPreviewUrls(prev => prev.filter((_, i) => i !== index)); - // value에서 온 파일 삭제 - const newFiles = filesFromValue.filter((_, i) => i !== index); - onChange?.(multiple ? newFiles : ""); - }, [filesFromValue, multiple, onChange]); - - // 첫 번째 파일이 이미지인지 확인 - const firstFile = files[0]; - const isFirstFileImage = firstFile && ( - /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(firstFile) || - firstFile.includes("/preview/") || - firstFile.includes("/api/files/preview/") - ); - - return ( -
- {/* 메인 업로드 박스 - 이미지가 있으면 박스 안에 표시 */} -
!disabled && !firstFile && inputRef.current?.click()} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - handleFileSelect(e.target.files)} - className="hidden" - /> - - {firstFile ? ( - // 파일이 있으면 박스 안에 표시 -
- {isFirstFileImage ? ( - // 이미지 미리보기 - 업로드된 이미지 - ) : ( - // 일반 파일 -
- - - {firstFile.split("/").pop()} - -
- )} - {/* 호버 시 액션 버튼 */} -
- {isFirstFileImage && ( - - )} - - -
-
- ) : isUploading ? ( -
-
- 업로드 중... -
- ) : ( -
- -
- 클릭 - 또는 파일을 드래그하세요 -
-
- 최대 {formatFileSize(maxSize)} - {accept !== "*" && ` (${accept})`} -
-
- )} -
- - {/* 에러 메시지 */} - {error && ( -
{error}
- )} - - {/* 추가 파일 목록 (multiple일 때 2번째 파일부터) */} - {multiple && files.length > 1 && ( -
- {files.slice(1).map((file, index) => { - const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(file) || - file.includes("/preview/") || - file.includes("/api/files/preview/"); - - return ( -
- {isImage ? ( - {`파일 - ) : ( - - )} -
- -
-
- ); - })} - {/* 추가 버튼 */} -
!disabled && inputRef.current?.click()} - > - -
-
- )} -
- ); -}); -FileUploader.displayName = "FileUploader"; - -/** - * 이미지 업로드/표시 컴포넌트 - */ -const ImageUploader = forwardRef void; - multiple?: boolean; - accept?: string; - maxSize?: number; - preview?: boolean; - disabled?: boolean; - uploadEndpoint?: string; - className?: string; -}>(({ - value, - onChange, - multiple = false, - accept = "image/*", - maxSize = 10485760, - preview = true, - disabled, - uploadEndpoint = "/files/upload", - className -}, ref) => { - const inputRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const [previewUrl, setPreviewUrl] = useState(null); - - // objid를 미리보기 URL로 변환 - const toPreviewUrl = (val: any): string => { - if (!val) return ""; - const strVal = String(val); - if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal; - if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`; - return strVal; - }; - - // value를 URL 형태의 images 배열로 변환 - const rawImages = Array.isArray(value) ? value : value ? [value] : []; - const images = rawImages.map(toPreviewUrl).filter(Boolean); - - // 파일 선택 핸들러 - const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => { - if (!selectedFiles || selectedFiles.length === 0) return; - - setIsUploading(true); - - try { - const fileArray = Array.from(selectedFiles); - const uploadedUrls: string[] = []; - - for (const file of fileArray) { - // 미리보기 생성 - if (preview) { - const reader = new FileReader(); - reader.onload = () => setPreviewUrl(reader.result as string); - reader.readAsDataURL(file); - } - - const formData = new FormData(); - formData.append("files", file); - - try { - const response = await apiClient.post(uploadEndpoint, formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - - const data = response.data; - // 백엔드 응답: { success: true, files: [{ filePath, objid, ... }] } - if (data.success && data.files && data.files.length > 0) { - const uploadedFile = data.files[0]; - // objid만 저장 (DB 저장용) - 표시는 V2MediaRenderer에서 URL로 변환 - uploadedUrls.push(String(uploadedFile.objid)); - } else if (data.objid) { - uploadedUrls.push(String(data.objid)); - } else if (data.url) { - uploadedUrls.push(data.url); - } else if (data.filePath) { - uploadedUrls.push(data.filePath); - } - } catch (err) { - console.error("이미지 업로드 실패:", err); - } - } - - if (multiple) { - onChange?.([...images, ...uploadedUrls]); - } else { - onChange?.(uploadedUrls[0] || ""); - } - } finally { - setIsUploading(false); - setPreviewUrl(null); - } - }, [images, multiple, preview, uploadEndpoint, onChange]); - - // 이미지 삭제 핸들러 - const handleRemove = useCallback((index: number) => { - const newImages = images.filter((_, i) => i !== index); - onChange?.(multiple ? newImages : ""); - }, [images, multiple, onChange]); - - // 첫 번째 이미지 (메인 박스에 표시) - const mainImage = images[0]; - // 추가 이미지들 (multiple일 때만) - const additionalImages = multiple ? images.slice(1) : []; - - return ( -
- {/* 메인 업로드 박스 - 첫 번째 이미지가 있으면 박스 안에 표시 */} -
!disabled && !mainImage && inputRef.current?.click()} - onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} - onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} - onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFileSelect(e.dataTransfer.files); }} - > - handleFileSelect(e.target.files)} - className="hidden" - /> - - {mainImage ? ( - // 이미지가 있으면 박스 안에 표시 -
- 업로드된 이미지 - {/* 호버 시 액션 버튼 */} -
- - - -
-
- ) : isUploading ? ( -
-
- 업로드 중... -
- ) : ( -
- - - 클릭 또는 파일을 드래그하세요 - - - 최대 {Math.round(maxSize / 1024 / 1024)} MB (*/*) - -
- )} -
- - {/* 추가 이미지 목록 (multiple일 때만) */} - {multiple && additionalImages.length > 0 && ( -
- {additionalImages.map((src, index) => ( -
- {`이미지 -
- -
-
- ))} - {/* 추가 버튼 */} -
!disabled && inputRef.current?.click()} - > - -
-
- )} -
- ); -}); -ImageUploader.displayName = "ImageUploader"; - -/** - * 비디오 컴포넌트 - */ -const VideoPlayer = forwardRef(({ value, className }, ref) => { - if (!value) { - return ( -
-
- ); + if (["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext)) { + return ; + } + if (["mp4", "avi", "mov", "wmv", "flv", "webm"].includes(ext)) { + return ; + } + if (["mp3", "wav", "flac", "aac", "ogg"].includes(ext)) { + return ; + } + if (["pdf"].includes(ext)) { + return ; + } + if (["doc", "docx", "hwp", "hwpx", "pages"].includes(ext)) { + return ; + } + if (["xls", "xlsx", "hcell", "numbers"].includes(ext)) { + return ; + } + if (["ppt", "pptx", "hanshow", "keynote"].includes(ext)) { + return ; + } + if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) { + return ; } - return ( -
-
- ); -}); -VideoPlayer.displayName = "VideoPlayer"; + return ; +}; /** - * 오디오 컴포넌트 - */ -const AudioPlayer = forwardRef(({ value, className }, ref) => { - if (!value) { - return ( -
- -
- ); - } - - return ( -
-
- ); -}); -AudioPlayer.displayName = "AudioPlayer"; - -/** - * 메인 V2Media 컴포넌트 + * V2 미디어 컴포넌트 (레거시 기능 통합) */ export const V2Media = forwardRef( (props, ref) => { @@ -661,125 +90,814 @@ export const V2Media = forwardRef( config: configProp, value, onChange, + formData, + columnName, + tableName, + onFormDataChange, + isDesignMode = false, + isInteractive = true, + onUpdate, + ...restProps } = props; - // config가 없으면 기본값 사용 - const config = configProp || { type: "image" as const }; + // 인증 정보 + const { user } = useAuth(); + + // config 기본값 + const config = configProp || { type: "file" as const }; + const mediaType = config.type || "file"; + + // 파일 상태 + const [uploadedFiles, setUploadedFiles] = useState([]); + const [uploadStatus, setUploadStatus] = useState<"idle" | "uploading" | "success" | "error">("idle"); + const [dragOver, setDragOver] = useState(false); + const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); - // objid를 미리보기 URL로 변환하는 함수 - const toPreviewUrl = (val: any): string => { - if (!val) return ""; - const strVal = String(val); - if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal; - if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`; - return strVal; - }; + // 모달 상태 + const [viewerFile, setViewerFile] = useState(null); + const [isViewerOpen, setIsViewerOpen] = useState(false); + const [isFileManagerOpen, setIsFileManagerOpen] = useState(false); - // value를 URL로 변환 (배열 또는 단일 값) - const convertedValue = Array.isArray(value) - ? value.map(toPreviewUrl) - : value ? toPreviewUrl(value) : value; - - console.log("[V2Media] original value:", value, "-> converted:", convertedValue, "onChange:", typeof onChange); + const fileInputRef = useRef(null); - // 타입별 미디어 컴포넌트 렌더링 - const renderMedia = () => { - const isDisabled = disabled || readonly; - const mediaType = config.type || "image"; + // 레코드 모드 판단 + const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); + const recordTableName = formData?.tableName || tableName; + const recordId = formData?.id; + const effectiveColumnName = isRecordMode ? 'attachments' : (columnName || id || 'attachments'); - switch (mediaType) { - case "file": - return ( - - ); - - case "image": - return ( - - ); - - case "video": - return ( - - ); - - case "audio": - return ( - - ); - - default: - return ( - - ); + // 레코드용 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(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?.screenId; + + 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?.screenId, + 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?.screenId, 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?.screenId; + 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?.companyCode || (window as any).__user__?.companyCode; + + 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, + companyCode: file.company_code || file.companyCode, + 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?.screenId, + 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) { + console.log("📝 [V2Media] formData 업데이트:", { + columnName: targetColumn, + fileIds, + isRecordMode: effectiveIsRecordMode, + }); + // (fieldName: string, value: any) 형식으로 호출 + onFormDataChange(targetColumn, fileIds); + } + + // 그리드 파일 상태 새로고침 이벤트 발생 + 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) { + console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", { + columnName: targetColumn, + fileIds, + }); + // (fieldName: string, value: any) 형식으로 호출 + onFormDataChange(targetColumn, fileIds); + } + + 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) => { + 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; - // 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) - const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; - const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; - const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; - return (
- {/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} + {/* 라벨 */} {showLabel && ( )} -
- {renderMedia()} + + {/* 메인 컨테이너 */} +
+
+ {/* 숨겨진 파일 입력 */} + + + {/* 파일이 있는 경우: 대표 이미지/파일 표시 */} + {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 ? ( +
+ {representativeFile.realFileName} +
+ ) : isImage && !representativeImageUrl ? ( +
+
+

이미지 로딩 중...

+
+ ) : ( +
+ {getFileIcon(representativeFile.fileExt)} +

+ {representativeFile.realFileName} +

+ + 대표 파일 + +
+ )} + + {/* 우측 하단 자세히보기 버튼 */} +
+ +
+ + ); + })() : ( + // 파일이 없는 경우: 업로드 안내 +
!disabled && !readonly && handleFileSelect()} + > + +

파일을 드래그하거나 클릭하세요

+

+ 최대 {formatFileSize(config.maxSize || 10 * 1024 * 1024)} + {config.accept && config.accept !== "*/*" && ` (${config.accept})`} +

+ +
+ )} +
+ + {/* 파일 뷰어 모달 */} + + + {/* 파일 관리 모달 */} + setIsFileManagerOpen(false)} + uploadedFiles={uploadedFiles} + onFileUpload={handleFileUpload} + onFileDownload={handleFileDownload} + onFileDelete={handleFileDelete} + onFileView={handleFileView} + onSetRepresentative={handleSetRepresentative} + config={fileConfig} + isDesignMode={isDesignMode} + />
); } @@ -788,4 +906,3 @@ export const V2Media = forwardRef( V2Media.displayName = "V2Media"; export default V2Media; - diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index ee80d0d7..5c66ba00 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -567,9 +567,10 @@ export const V2Repeater: React.FC = ({ ); // 🆕 채번 API 호출 (비동기) - const generateNumberingCode = useCallback(async (ruleId: string): Promise => { + // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 + const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { try { - const result = await allocateNumberingCode(ruleId); + const result = await allocateNumberingCode(ruleId, userInputCode, formData); if (result.success && result.data?.generatedCode) { return result.data.generatedCode; } @@ -690,7 +691,8 @@ export const V2Repeater: React.FC = ({ if (match) { const ruleId = match[1]; try { - const result = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const result = await allocateNumberingCode(ruleId, undefined, newRow); if (result.success && result.data?.generatedCode) { newRow[key] = result.data.generatedCode; } else { diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index 3a9b7930..0800e752 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -139,12 +139,20 @@ export async function previewNumberingCode( /** * 코드 할당 (저장 시점에 실제 순번 증가) * 실제 저장할 때만 호출 + * @param ruleId 채번 규칙 ID + * @param userInputCode 사용자가 편집한 최종 코드 (수동 입력 부분 추출용) + * @param formData 폼 데이터 (카테고리/날짜 기반 채번용) */ export async function allocateNumberingCode( - ruleId: string + ruleId: string, + userInputCode?: string, + formData?: Record ): Promise> { try { - const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`); + const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`, { + userInputCode, + formData, + }); return response.data; } catch (error: any) { return { success: false, error: error.message || "코드 할당 실패" }; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 4fe888bf..73bd9795 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -427,9 +427,10 @@ export const DynamicComponentRenderer: React.FC = // 컴포넌트의 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, diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 16cf7dfc..0cfdd542 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -856,8 +856,10 @@ export function RepeatScreenModalComponent({ }); // 채번 API 호출 (allocate: 실제 시퀀스 증가) + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); - const response = await allocateNumberingCode(rowNumbering.numberingRuleId); + const userInputCode = newRowData[rowNumbering.targetColumn] as string; + const response = await allocateNumberingCode(rowNumbering.numberingRuleId, userInputCode, newRowData); if (response.success && response.data) { newRowData[rowNumbering.targetColumn] = response.data.generatedCode; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index ca4d57d0..0f5c851b 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -1443,8 +1443,9 @@ export function UniversalFormModalComponent({ if (isNewRecord || hasNoValue) { try { - // allocateNumberingCode로 실제 순번 증가 - const response = await allocateNumberingCode(field.numberingRule.ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const userInputCode = mainData[field.columnName] as string; + const response = await allocateNumberingCode(field.numberingRule.ruleId, userInputCode, mainData); if (response.success && response.data?.generatedCode) { mainData[field.columnName] = response.data.generatedCode; } diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index d6ed349f..fcf2e97f 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -1325,7 +1325,31 @@ export const ButtonPrimaryComponent: React.FC = ({ ...userStyle, }; - const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; + // 버튼 텍스트 결정 (다양한 소스에서 가져옴) + // "기본 버튼"은 컴포넌트 생성 시 기본값이므로 무시 + const labelValue = component.label === "기본 버튼" ? undefined : component.label; + + // 액션 타입에 따른 기본 텍스트 (modal 액션과 동일하게) + const actionType = processedConfig.action?.type || component.componentConfig?.action?.type; + const actionDefaultText: Record = { + save: "저장", + delete: "삭제", + modal: "등록", + edit: "수정", + copy: "복사", + close: "닫기", + cancel: "취소", + }; + + const buttonContent = + processedConfig.text || + component.webTypeConfig?.text || + component.componentConfig?.text || + component.config?.text || + component.style?.labelText || + labelValue || + actionDefaultText[actionType as string] || + "버튼"; return ( <> diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index e67a8399..c656d8db 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -25,8 +25,20 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer { // 값 변경 핸들러 const handleChange = (value: any) => { + console.log("🔄 [V2InputRenderer] handleChange 호출:", { + columnName, + value, + isInteractive, + hasOnFormDataChange: !!onFormDataChange, + }); if (isInteractive && onFormDataChange && columnName) { onFormDataChange(columnName, value); + } else { + console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", { + isInteractive, + hasOnFormDataChange: !!onFormDataChange, + columnName, + }); } }; diff --git a/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx b/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx index 0cfd5393..af923ec3 100644 --- a/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx +++ b/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx @@ -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 ( - ); } diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index c99f9876..02ef8643 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -459,6 +459,9 @@ export const TableListComponent: React.FC = ({ // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 const [filterGroups, setFilterGroups] = useState([]); + + // 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함 + const [joinColumnMapping, setJoinColumnMapping] = useState>({}); // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터 const filteredData = useMemo(() => { @@ -473,14 +476,17 @@ export const TableListComponent: React.FC = ({ }); } - // 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용) + // 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함) if (Object.keys(headerFilters).length > 0) { result = result.filter((row) => { return Object.entries(headerFilters).every(([columnName, values]) => { if (values.size === 0) return true; - // 여러 가능한 컬럼명 시도 - const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; + // joinColumnMapping을 사용하여 조인된 컬럼명 확인 + const mappedColumnName = joinColumnMapping[columnName] || columnName; + + // 여러 가능한 컬럼명 시도 (mappedColumnName 우선) + const cellValue = row[mappedColumnName] ?? row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; return values.has(cellStr); @@ -541,7 +547,7 @@ export const TableListComponent: React.FC = ({ } return result; - }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]); + }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); @@ -554,7 +560,6 @@ export const TableListComponent: React.FC = ({ const [tableLabel, setTableLabel] = useState(""); const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); const [displayColumns, setDisplayColumns] = useState([]); - const [joinColumnMapping, setJoinColumnMapping] = useState>({}); const [columnMeta, setColumnMeta] = useState< Record >({}); diff --git a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx index a3bde9a4..94d0c742 100644 --- a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx @@ -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) => { diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 3521c668..28257a9f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -737,7 +737,9 @@ export class ButtonActionExecutor { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) { try { - const allocateResult = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const userInputCode = context.formData[fieldName] as string; + const allocateResult = await allocateNumberingCode(ruleId, userInputCode, context.formData); if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; @@ -1030,7 +1032,9 @@ export class ButtonActionExecutor { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { - const allocateResult = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const userInputCode = formData[fieldName] as string; + const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData); if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; @@ -2063,7 +2067,9 @@ export class ButtonActionExecutor { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { - const allocateResult = await allocateNumberingCode(ruleId); + // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) + const userInputCode = commonFieldsData[fieldName] as string; + const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData); if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; @@ -3494,10 +3500,13 @@ export class ButtonActionExecutor { const screenModalEvent = new CustomEvent("openScreenModal", { detail: { screenId: config.targetScreenId, - title: config.editModalTitle || "데이터 수정", + title: isCreateMode ? config.editModalTitle || "데이터 복사" : config.editModalTitle || "데이터 수정", description: description, size: config.modalSize || "lg", - editData: rowData, // 🆕 수정 데이터 전달 + // 🔧 복사 모드에서는 editData 대신 splitPanelParentData로 전달하여 채번이 생성되도록 함 + editData: isCreateMode ? undefined : rowData, + splitPanelParentData: isCreateMode ? rowData : undefined, + isCreateMode: isCreateMode, // 🆕 복사 모드 플래그 전달 }, }); window.dispatchEvent(screenModalEvent); diff --git a/frontend/lib/utils/webTypeMapping.ts b/frontend/lib/utils/webTypeMapping.ts index ed4acba2..ebff5fb8 100644 --- a/frontend/lib/utils/webTypeMapping.ts +++ b/frontend/lib/utils/webTypeMapping.ts @@ -107,18 +107,18 @@ export const WEB_TYPE_V2_MAPPING: Record = { 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 = { 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", }; diff --git a/frontend/types/v2-components.ts b/frontend/types/v2-components.ts index d985699d..c0d6ca53 100644 --- a/frontend/types/v2-components.ts +++ b/frontend/types/v2-components.ts @@ -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; + columnName?: string; + tableName?: string; + // 부모 컴포넌트 시그니처: (fieldName, value) 형식 + onFormDataChange?: (fieldName: string, value: any) => void; + isDesignMode?: boolean; + isInteractive?: boolean; + onUpdate?: (updates: Partial) => void; } // ===== V2List ===== diff --git a/scripts/dev/start-all-parallel.bat b/scripts/dev/start-all-parallel.bat index ea10551e..08049b48 100644 --- a/scripts/dev/start-all-parallel.bat +++ b/scripts/dev/start-all-parallel.bat @@ -26,12 +26,14 @@ if %errorlevel% neq 0 ( echo [OK] Docker Desktop이 실행 중입니다. echo. -REM 기존 컨테이너 정리 -echo [2/5] 기존 컨테이너 정리 중... +REM 기존 컨테이너 및 이미지 정리 +echo [2/5] 기존 컨테이너 및 이미지 정리 중... docker rm -f pms-backend-win pms-frontend-win 2>nul +docker rmi -f erp-node-backend erp-node-frontend 2>nul docker network rm pms-network 2>nul docker network create pms-network 2>nul -echo [OK] 컨테이너 정리 완료 +docker system prune -f >nul 2>&1 +echo [OK] 컨테이너 및 이미지 정리 완료 echo. REM 병렬 빌드 (docker-compose 자체가 병렬 처리) @@ -39,8 +41,8 @@ echo [3/5] 이미지 빌드 중... (백엔드 + 프론트엔드 병렬) echo 이 작업은 시간이 걸릴 수 있습니다... echo. -REM 백엔드 빌드 -docker-compose -f docker-compose.backend.win.yml build +REM 백엔드 빌드 (캐시 없이 완전 재빌드) +docker-compose -f docker-compose.backend.win.yml build --no-cache if %errorlevel% neq 0 ( echo [ERROR] 백엔드 빌드 실패! pause @@ -49,8 +51,8 @@ if %errorlevel% neq 0 ( echo [OK] 백엔드 빌드 완료 echo. -REM 프론트엔드 빌드 -docker-compose -f docker-compose.frontend.win.yml build +REM 프론트엔드 빌드 (캐시 없이 완전 재빌드) +docker-compose -f docker-compose.frontend.win.yml build --no-cache if %errorlevel% neq 0 ( echo [ERROR] 프론트엔드 빌드 실패! pause