diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts index a3eecb61..fa19c0a0 100644 --- a/backend-node/src/services/masterDetailExcelService.ts +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -413,6 +413,16 @@ class MasterDetailExcelService { ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // 디테일 테이블의 id 컬럼 존재 여부 확인 (user_info 등 id가 없는 테이블 대응) + const detailIdCheck = await queryOne<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'id' + ) as exists`, + [detailTable] + ); + const detailOrderColumn = detailIdCheck?.exists ? `d."id"` : `d."${detailFkColumn}"`; + // JOIN 쿼리 실행 const sql = ` SELECT ${selectClause} @@ -422,7 +432,7 @@ class MasterDetailExcelService { AND m.company_code = d.company_code ${entityJoinClauses} ${whereClause} - ORDER BY m."${masterKeyColumn}", d.id + ORDER BY m."${masterKeyColumn}", ${detailOrderColumn} `; logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params }); @@ -481,14 +491,67 @@ class MasterDetailExcelService { } } + /** + * 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환 + * 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback + */ + private async detectNumberingRuleForColumn( + tableName: string, + columnName: string, + companyCode?: string + ): Promise<{ numberingRuleId: string } | null> { + try { + // 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저) + const companyCondition = companyCode && companyCode !== "*" + ? `AND company_code IN ($3, '*')` + : `AND company_code = '*'`; + const params = companyCode && companyCode !== "*" + ? [tableName, columnName, companyCode] + : [tableName, columnName]; + + const result = await query( + `SELECT input_type, detail_settings, company_code + FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 ${companyCondition} + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + params + ); + + logger.info(`채번 컬럼 조회 결과: ${tableName}.${columnName}`, { + rowCount: result.length, + rows: result.map((r: any) => ({ input_type: r.input_type, company_code: r.company_code })), + }); + + // 채번 타입인 행 찾기 (회사별 우선) + for (const row of result) { + if (row.input_type === "numbering") { + const settings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings || "{}") + : row.detail_settings; + + if (settings?.numberingRuleId) { + logger.info(`채번 컬럼 감지: ${tableName}.${columnName} → 규칙 ID: ${settings.numberingRuleId} (company: ${row.company_code})`); + return { numberingRuleId: settings.numberingRuleId }; + } + } + } + + logger.info(`채번 컬럼 아님: ${tableName}.${columnName}`); + return null; + } catch (error) { + logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error); + return null; + } + } + /** * 마스터-디테일 데이터 업로드 (엑셀 업로드용) * * 처리 로직: - * 1. 엑셀 데이터를 마스터 키로 그룹화 - * 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT - * 3. 해당 마스터 키의 기존 디테일 삭제 - * 4. 새 디테일 데이터 INSERT + * 1. 마스터 키 컬럼이 채번 타입인지 확인 + * 2-A. 채번인 경우: 다른 마스터 컬럼 값으로 그룹화 → 키 자동 생성 → INSERT + * 2-B. 채번 아닌 경우: 마스터 키 값으로 그룹화 → UPSERT + * 3. 디테일 데이터 INSERT */ async uploadJoinedData( relation: MasterDetailRelation, @@ -513,94 +576,164 @@ class MasterDetailExcelService { const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; - // 1. 데이터를 마스터 키로 그룹화 - const groupedData = new Map[]>(); - - for (const row of data) { - const masterKey = row[masterKeyColumn]; - if (!masterKey) { - result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); - continue; - } + // 마스터/디테일 테이블의 실제 컬럼 존재 여부 확인 (writer, created_date 등 하드코딩 방지) + const masterColsResult = await client.query( + `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, + [masterTable] + ); + const masterExistingCols = new Set(masterColsResult.rows.map((r: any) => r.column_name)); - if (!groupedData.has(masterKey)) { - groupedData.set(masterKey, []); + const detailColsResult = await client.query( + `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, + [detailTable] + ); + const detailExistingCols = new Set(detailColsResult.rows.map((r: any) => r.column_name)); + + // 마스터 키 컬럼의 채번 규칙 자동 감지 (회사별 설정 우선) + const numberingInfo = await this.detectNumberingRuleForColumn(masterTable, masterKeyColumn, companyCode); + const isAutoNumbering = !!numberingInfo; + + logger.info(`마스터 키 채번 감지:`, { + masterKeyColumn, + isAutoNumbering, + numberingRuleId: numberingInfo?.numberingRuleId + }); + + // 데이터 그룹화 + const groupedData = new Map[]>(); + + if (isAutoNumbering) { + // 채번 모드: 마스터 키 제외한 다른 마스터 컬럼 값으로 그룹화 + const otherMasterCols = masterColumns.filter(c => c.name !== masterKeyColumn).map(c => c.name); + + for (const row of data) { + // 다른 마스터 컬럼 값들을 조합해 그룹 키 생성 + const groupKey = otherMasterCols.map(col => row[col] ?? "").join("|||"); + if (!groupedData.has(groupKey)) { + groupedData.set(groupKey, []); + } + groupedData.get(groupKey)!.push(row); } - groupedData.get(masterKey)!.push(row); + + logger.info(`채번 모드 그룹화 완료: ${groupedData.size}개 그룹 (기준: ${otherMasterCols.join(", ")})`); + } else { + // 일반 모드: 마스터 키 값으로 그룹화 + for (const row of data) { + const masterKey = row[masterKeyColumn]; + if (!masterKey) { + result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); + continue; + } + if (!groupedData.has(masterKey)) { + groupedData.set(masterKey, []); + } + groupedData.get(masterKey)!.push(row); + } + + logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`); } - logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`); - - // 2. 각 그룹 처리 - for (const [masterKey, rows] of groupedData.entries()) { + // 각 그룹 처리 + for (const [groupKey, rows] of groupedData.entries()) { try { - // 2a. 마스터 데이터 추출 (첫 번째 행에서) + // 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키) + let masterKey: string; + + if (isAutoNumbering) { + // 채번 규칙으로 마스터 키 자동 생성 + masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode); + logger.info(`채번 생성: ${masterKey}`); + } else { + masterKey = groupKey; + } + + // 마스터 데이터 추출 (첫 번째 행에서) const masterData: Record = {}; + // 마스터 키 컬럼은 항상 설정 (분할패널 컬럼 목록에 없어도) + masterData[masterKeyColumn] = masterKey; for (const col of masterColumns) { + if (col.name === masterKeyColumn) continue; // 이미 위에서 설정 if (rows[0][col.name] !== undefined) { masterData[col.name] = rows[0][col.name]; } } - // 회사 코드, 작성자 추가 - masterData.company_code = companyCode; - if (userId) { + // 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만) + if (masterExistingCols.has("company_code")) { + masterData.company_code = companyCode; + } + if (userId && masterExistingCols.has("writer")) { masterData.writer = userId; } - // 2b. 마스터 UPSERT - const existingMaster = await client.query( - `SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, - [masterKey, companyCode] - ); + // INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가) + const buildInsertSQL = (table: string, data: Record, existingCols: Set) => { + const cols = Object.keys(data); + const hasCreatedDate = existingCols.has("created_date"); + const colList = hasCreatedDate ? [...cols, "created_date"] : cols; + const placeholders = cols.map((_, i) => `$${i + 1}`); + const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders; + const values = cols.map(k => data[k]); + return { + sql: `INSERT INTO "${table}" (${colList.map(c => `"${c}"`).join(", ")}) VALUES (${valList.join(", ")})`, + values, + }; + }; - if (existingMaster.rows.length > 0) { - // UPDATE - const updateCols = Object.keys(masterData) - .filter(k => k !== masterKeyColumn && k !== "id") - .map((k, i) => `"${k}" = $${i + 1}`); - const updateValues = Object.keys(masterData) - .filter(k => k !== masterKeyColumn && k !== "id") - .map(k => masterData[k]); - - if (updateCols.length > 0) { - await client.query( - `UPDATE "${masterTable}" - SET ${updateCols.join(", ")}, updated_date = NOW() - WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, - [...updateValues, masterKey, companyCode] - ); - } - result.masterUpdated++; - } else { - // INSERT - const insertCols = Object.keys(masterData); - const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); - const insertValues = insertCols.map(k => masterData[k]); - - await client.query( - `INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) - VALUES (${insertPlaceholders.join(", ")}, NOW())`, - insertValues - ); + if (isAutoNumbering) { + // 채번 모드: 항상 INSERT (새 마스터 생성) + const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols); + await client.query(sql, values); result.masterInserted++; + } else { + // 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT) + const existingMaster = await client.query( + `SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + + if (existingMaster.rows.length > 0) { + const updateCols = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map((k, i) => `"${k}" = $${i + 1}`); + const updateValues = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map(k => masterData[k]); + + if (updateCols.length > 0) { + const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : ""; + await client.query( + `UPDATE "${masterTable}" + SET ${updateCols.join(", ")}${updatedDateClause} + WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, + [...updateValues, masterKey, companyCode] + ); + } + result.masterUpdated++; + } else { + const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols); + await client.query(sql, values); + result.masterInserted++; + } + + // 일반 모드에서만 기존 디테일 삭제 (채번 모드는 새 마스터이므로 삭제할 디테일 없음) + const deleteResult = await client.query( + `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + result.detailDeleted += deleteResult.rowCount || 0; } - // 2c. 기존 디테일 삭제 - const deleteResult = await client.query( - `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, - [masterKey, companyCode] - ); - result.detailDeleted += deleteResult.rowCount || 0; - - // 2d. 새 디테일 INSERT + // 디테일 INSERT for (const row of rows) { const detailData: Record = {}; - // FK 컬럼 추가 + // FK 컬럼에 마스터 키 주입 detailData[detailFkColumn] = masterKey; - detailData.company_code = companyCode; - if (userId) { + if (detailExistingCols.has("company_code")) { + detailData.company_code = companyCode; + } + if (userId && detailExistingCols.has("writer")) { detailData.writer = userId; } @@ -611,20 +744,13 @@ class MasterDetailExcelService { } } - const insertCols = Object.keys(detailData); - const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); - const insertValues = insertCols.map(k => detailData[k]); - - await client.query( - `INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) - VALUES (${insertPlaceholders.join(", ")}, NOW())`, - insertValues - ); + const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols); + await client.query(sql, values); result.detailInserted++; } } catch (error: any) { - result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`); - logger.error(`마스터 키 ${masterKey} 처리 실패:`, error); + result.errors.push(`그룹 처리 실패: ${error.message}`); + logger.error(`그룹 처리 실패:`, error); } } diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index f6429a09..067d3a45 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -84,12 +84,9 @@ export interface ExcelUploadModalProps { masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; }; - // 🆕 마스터-디테일 엑셀 업로드 설정 + // 마스터-디테일 엑셀 업로드 설정 masterDetailExcelConfig?: MasterDetailExcelConfig; - // 🆕 단일 테이블 채번 설정 - numberingRuleId?: string; - numberingTargetColumn?: string; - // 🆕 업로드 후 제어 실행 설정 + // 업로드 후 제어 실행 설정 afterUploadFlows?: Array<{ flowId: string; order: number }>; } @@ -112,9 +109,6 @@ export const ExcelUploadModal: React.FC = ({ isMasterDetail = false, masterDetailRelation, masterDetailExcelConfig, - // 단일 테이블 채번 설정 - numberingRuleId, - numberingTargetColumn, // 업로드 후 제어 실행 설정 afterUploadFlows, }) => { @@ -627,6 +621,44 @@ export const ExcelUploadModal: React.FC = ({ setCurrentStep((prev) => Math.max(prev - 1, 1)); }; + // 테이블 타입 관리에서 채번 컬럼 자동 감지 + const detectNumberingColumn = async ( + targetTableName: string + ): Promise<{ columnName: string; numberingRuleId: string } | null> => { + try { + const { getTableColumns } = await import("@/lib/api/tableManagement"); + const response = await getTableColumns(targetTableName); + + if (response.success && response.data?.columns) { + for (const col of response.data.columns) { + if (col.inputType === "numbering") { + try { + const settings = + typeof col.detailSettings === "string" + ? JSON.parse(col.detailSettings) + : col.detailSettings; + if (settings?.numberingRuleId) { + console.log( + `✅ 채번 컬럼 자동 감지: ${col.columnName} → 규칙 ID: ${settings.numberingRuleId}` + ); + return { + columnName: col.columnName, + numberingRuleId: settings.numberingRuleId, + }; + } + } catch { + // detailSettings 파싱 실패 시 무시 + } + } + } + } + return null; + } catch (error) { + console.error("채번 컬럼 감지 실패:", error); + return null; + } + }; + // 업로드 핸들러 const handleUpload = async () => { if (!file || !tableName) { @@ -667,19 +699,24 @@ export const ExcelUploadModal: React.FC = ({ `📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행` ); - // 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번) + // 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번 자동 감지) if (isSimpleMasterDetailMode && screenId && masterDetailRelation) { + // 마스터 테이블에서 채번 컬럼 자동 감지 + const masterNumberingInfo = await detectNumberingColumn(masterDetailRelation.masterTable); + const detectedNumberingRuleId = masterNumberingInfo?.numberingRuleId || masterDetailExcelConfig?.numberingRuleId; + console.log("📊 마스터-디테일 간단 모드 업로드:", { masterDetailRelation, masterFieldValues, - numberingRuleId: masterDetailExcelConfig?.numberingRuleId, + detectedNumberingRuleId, + autoDetected: !!masterNumberingInfo, }); const uploadResult = await DynamicFormApi.uploadMasterDetailSimple( screenId, filteredData, masterFieldValues, - masterDetailExcelConfig?.numberingRuleId || undefined, + detectedNumberingRuleId || undefined, masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성 masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어 ); @@ -704,6 +741,24 @@ export const ExcelUploadModal: React.FC = ({ else if (isMasterDetail && screenId && masterDetailRelation) { console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation); + // 마스터 키 컬럼 매핑 검증 (채번 타입이면 자동 생성되므로 검증 생략) + const masterKeyCol = masterDetailRelation.masterKeyColumn; + const hasMasterKey = filteredData.length > 0 && filteredData[0][masterKeyCol] !== undefined && filteredData[0][masterKeyCol] !== null && filteredData[0][masterKeyCol] !== ""; + if (!hasMasterKey) { + // 채번 여부 확인 - 채번이면 백엔드에서 자동 생성하므로 통과 + const numberingInfo = await detectNumberingColumn(masterDetailRelation.masterTable); + const isMasterKeyAutoNumbering = numberingInfo && numberingInfo.columnName === masterKeyCol; + + if (!isMasterKeyAutoNumbering) { + toast.error( + `마스터 키 컬럼(${masterKeyCol})이 매핑되지 않았습니다. 컬럼 매핑에서 [마스터] 항목을 확인해주세요.` + ); + setIsUploading(false); + return; + } + console.log(`✅ 마스터 키(${masterKeyCol})는 채번 타입 → 백엔드에서 자동 생성`); + } + const uploadResult = await DynamicFormApi.uploadMasterDetailData( screenId, filteredData @@ -731,8 +786,9 @@ export const ExcelUploadModal: React.FC = ({ let skipCount = 0; let overwriteCount = 0; - // 단일 테이블 채번 설정 확인 - const hasNumbering = numberingRuleId && numberingTargetColumn; + // 단일 테이블 채번 자동 감지 (테이블 타입 관리에서 input_type = 'numbering' 컬럼) + const numberingInfo = await detectNumberingColumn(tableName); + const hasNumbering = !!numberingInfo; // 중복 체크 설정 확인 const duplicateCheckMappings = columnMappings.filter( @@ -816,14 +872,14 @@ export const ExcelUploadModal: React.FC = ({ continue; } - // 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만) - if (hasNumbering && uploadMode === "insert" && !shouldUpdate) { + // 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용) + if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { try { const { apiClient } = await import("@/lib/api/client"); - const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`); + const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`); const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code; if (numberingResponse.data?.success && generatedCode) { - dataToSave[numberingTargetColumn] = generatedCode; + dataToSave[numberingInfo.columnName] = generatedCode; } } catch (numError) { console.error("채번 오류:", numError); diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 8d6df989..ea2febb1 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -3777,7 +3777,7 @@ export const ButtonConfigPanel: React.FC = ({ /** * 마스터-디테일 엑셀 업로드 설정 컴포넌트 - * 분할 패널 + column_labels에서 관계를 자동 감지하고, 사용자는 채번 규칙만 선택 + * 분할 패널 + column_labels에서 관계를 자동 감지 (채번은 테이블 타입 관리에서 자동 감지) */ const MasterDetailExcelUploadConfig: React.FC<{ config: any; @@ -4005,7 +4005,7 @@ const MasterDetailExcelUploadConfig: React.FC<{ {/* 마스터 키 자동 생성 안내 */} {relationInfo && (

- 마스터 테이블의 {relationInfo.masterKeyColumn} 값은 위에서 설정한 채번 규칙으로 자동 + 마스터 테이블의 {relationInfo.masterKeyColumn} 값은 테이블 타입 관리에서 설정된 채번 규칙으로 자동 생성됩니다.

)} @@ -4114,165 +4114,15 @@ const MasterDetailExcelUploadConfig: React.FC<{ }; /** - * 엑셀 업로드 채번 규칙 설정 (단일 테이블/마스터-디테일 모두 사용 가능) + * 엑셀 업로드 채번 규칙 안내 (테이블 타입 관리에서 자동 감지) */ -const ExcelNumberingRuleConfig: React.FC<{ - config: { numberingRuleId?: string; numberingTargetColumn?: string }; - updateConfig: (updates: { numberingRuleId?: string; numberingTargetColumn?: string }) => void; - tableName?: string; // 단일 테이블인 경우 테이블명 - hasSplitPanel?: boolean; // 분할 패널 여부 (마스터-디테일) -}> = ({ config, updateConfig, tableName, hasSplitPanel }) => { - const [numberingRules, setNumberingRules] = useState([]); - const [ruleSelectOpen, setRuleSelectOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [tableColumns, setTableColumns] = useState>([]); - const [columnsLoading, setColumnsLoading] = useState(false); - - // 채번 규칙 목록 로드 - useEffect(() => { - const loadNumberingRules = async () => { - setIsLoading(true); - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get("/numbering-rules"); - if (response.data?.success && response.data?.data) { - setNumberingRules(response.data.data); - } - } catch (error) { - console.error("채번 규칙 목록 로드 실패:", error); - } finally { - setIsLoading(false); - } - }; - - loadNumberingRules(); - }, []); - - // 단일 테이블인 경우 컬럼 목록 로드 - useEffect(() => { - if (!tableName || hasSplitPanel) { - setTableColumns([]); - return; - } - - const loadColumns = async () => { - setColumnsLoading(true); - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); - if (response.data?.success && response.data?.data?.columns) { - const cols = response.data.data.columns.map((col: any) => ({ - columnName: col.columnName || col.column_name, - columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, - })); - setTableColumns(cols); - } - } catch (error) { - console.error("컬럼 목록 로드 실패:", error); - } finally { - setColumnsLoading(false); - } - }; - - loadColumns(); - }, [tableName, hasSplitPanel]); - - const selectedRule = numberingRules.find((r) => String(r.rule_id || r.ruleId) === String(config.numberingRuleId)); - +const ExcelNumberingRuleInfo: React.FC = () => { return (
-

- 업로드 시 자동으로 생성할 코드/번호의 채번 규칙을 선택하세요. +

+ 테이블 타입 관리에서 "채번" 타입으로 설정된 컬럼의 채번 규칙이 업로드 시 자동으로 적용됩니다.

- - - - - - - - - - 검색 결과 없음 - - { - updateConfig({ numberingRuleId: undefined, numberingTargetColumn: undefined }); - setRuleSelectOpen(false); - }} - className="text-xs" - > - - 채번 없음 - - {numberingRules.map((rule, idx) => { - const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`); - const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)"; - return ( - { - updateConfig({ numberingRuleId: ruleId }); - setRuleSelectOpen(false); - }} - className="text-xs" - > - - {ruleName} - - ); - })} - - - - - - - {/* 단일 테이블이고 채번 규칙이 선택된 경우, 적용할 컬럼 선택 */} - {config.numberingRuleId && !hasSplitPanel && tableName && ( -
- - -

채번 값이 입력될 컬럼을 선택하세요.

-
- )} - - {/* 분할 패널인 경우 안내 메시지 */} - {config.numberingRuleId && hasSplitPanel && ( -

마스터-디테일 구조에서는 마스터 키 컬럼에 자동 적용됩니다.

- )}
); }; @@ -4440,14 +4290,10 @@ const ExcelUploadConfigSection: React.FC<{ allComponents: ComponentData[]; currentTableName?: string; // 현재 화면의 테이블명 (ButtonConfigPanel에서 전달) }> = ({ config, onUpdateProperty, allComponents, currentTableName: propTableName }) => { - // 엑셀 업로드 설정 상태 관리 + // 엑셀 업로드 설정 상태 관리 (채번은 테이블 타입 관리에서 자동 감지) const [excelUploadConfig, setExcelUploadConfig] = useState<{ - numberingRuleId?: string; - numberingTargetColumn?: string; afterUploadFlows?: Array<{ flowId: string; order: number }>; }>({ - numberingRuleId: config.action?.excelNumberingRuleId, - numberingTargetColumn: config.action?.excelNumberingTargetColumn, afterUploadFlows: config.action?.excelAfterUploadFlows || [], }); @@ -4529,17 +4375,11 @@ const ExcelUploadConfigSection: React.FC<{ ); }, [hasSplitPanel, singleTableName, propTableName]); - // 설정 업데이트 함수 + // 설정 업데이트 함수 (채번은 테이블 타입 관리에서 자동 감지되므로 제어 실행만 관리) const updateExcelUploadConfig = (updates: Partial) => { const newConfig = { ...excelUploadConfig, ...updates }; setExcelUploadConfig(newConfig); - if (updates.numberingRuleId !== undefined) { - onUpdateProperty("componentConfig.action.excelNumberingRuleId", updates.numberingRuleId); - } - if (updates.numberingTargetColumn !== undefined) { - onUpdateProperty("componentConfig.action.excelNumberingTargetColumn", updates.numberingTargetColumn); - } if (updates.afterUploadFlows !== undefined) { onUpdateProperty("componentConfig.action.excelAfterUploadFlows", updates.afterUploadFlows); } @@ -4548,15 +4388,9 @@ const ExcelUploadConfigSection: React.FC<{ // config 변경 시 로컬 상태 동기화 useEffect(() => { setExcelUploadConfig({ - numberingRuleId: config.action?.excelNumberingRuleId, - numberingTargetColumn: config.action?.excelNumberingTargetColumn, afterUploadFlows: config.action?.excelAfterUploadFlows || [], }); - }, [ - config.action?.excelNumberingRuleId, - config.action?.excelNumberingTargetColumn, - config.action?.excelAfterUploadFlows, - ]); + }, [config.action?.excelAfterUploadFlows]); return (
@@ -4595,13 +4429,8 @@ const ExcelUploadConfigSection: React.FC<{
)} - {/* 채번 규칙 설정 (항상 표시) */} - + {/* 채번 규칙 안내 (테이블 타입 관리에서 자동 감지) */} + {/* 업로드 후 제어 실행 (항상 표시) */} diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx index fcfd16cf..f6c5e4ea 100644 --- a/frontend/components/ui/input.tsx +++ b/frontend/components/ui/input.tsx @@ -55,7 +55,7 @@ const Input = React.forwardRef( type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index d8457adb..17183050 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -947,6 +947,21 @@ export const V2Input = forwardRef((props, ref) => const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백 + // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일) + // RealtimePreview 래퍼가 외부 div에 스타일을 적용하지만, + // 내부 input/textarea가 자체 Tailwind 테두리를 가지므로 이를 제거하여 외부 스타일이 보이도록 함 + const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border); + const hasCustomBackground = !!style?.backgroundColor; + const hasCustomRadius = !!style?.borderRadius; + + // 텍스트 스타일 오버라이드 (CSS 상속으로 내부 input에 전달) + const customTextStyle: React.CSSProperties = {}; + if (style?.color) customTextStyle.color = style.color; + if (style?.fontSize) customTextStyle.fontSize = style.fontSize; + if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; + if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; + const hasCustomText = Object.keys(customTextStyle).length > 0; + return (
((props, ref) => {required && *} )} -
+
{renderInput()}
diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index c4bd0925..c7ea8c94 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -947,6 +947,19 @@ export const V2Select = forwardRef( const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; + // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일) + const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border); + const hasCustomBackground = !!style?.backgroundColor; + const hasCustomRadius = !!style?.borderRadius; + + // 텍스트 스타일 오버라이드 (CSS 상속) + const customTextStyle: React.CSSProperties = {}; + if (style?.color) customTextStyle.color = style.color; + if (style?.fontSize) customTextStyle.fontSize = style.fontSize; + if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; + if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; + const hasCustomText = Object.keys(customTextStyle).length > 0; + return (
( {required && *} )} -
+
{renderSelect()}
diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index f8b154d6..5516a4bf 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -1283,13 +1283,17 @@ export const ButtonPrimaryComponent: React.FC = ({ width: buttonWidth, height: buttonHeight, minHeight: "32px", // 🔧 최소 높이를 32px로 줄임 - border: "none", - borderRadius: "0.5rem", + // 🔧 커스텀 테두리 스타일 (StyleEditor에서 설정한 값 우선) + border: style?.border || (style?.borderWidth ? undefined : "none"), + borderWidth: style?.borderWidth || undefined, + borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || undefined, + borderColor: style?.borderColor || undefined, + borderRadius: style?.borderRadius || "0.5rem", backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, - color: finalDisabled ? "#9ca3af" : buttonTextColor, // 🔧 webTypeConfig.textColor 지원 - // 🔧 크기 설정 적용 (sm/md/lg) - fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem", - fontWeight: "600", + color: finalDisabled ? "#9ca3af" : (style?.color || buttonTextColor), // 🔧 StyleEditor 텍스트 색상도 지원 + // 🔧 크기 설정 적용 (sm/md/lg), StyleEditor fontSize 우선 + fontSize: style?.fontSize || (componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem"), + fontWeight: style?.fontWeight || "600", cursor: finalDisabled ? "not-allowed" : "pointer", outline: "none", boxSizing: "border-box", diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index 9af1a58a..fc39458a 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { uploadFiles, downloadFile, deleteFile, getComponentFiles, getFileInfoByObjid, getFilePreviewUrl } from "@/lib/api/file"; import { GlobalFileManager } from "@/lib/api/globalFile"; -import { formatFileSize } from "@/lib/utils"; +import { formatFileSize, cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { FileViewerModal } from "./FileViewerModal"; import { FileManagerModal } from "./FileManagerModal"; @@ -492,11 +492,11 @@ const FileUploadComponent: React.FC = ({ // 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리 const safeComponentConfig = componentConfig || {}; const fileConfig = { + ...safeComponentConfig, accept: safeComponentConfig.accept || "*/*", multiple: safeComponentConfig.multiple || false, maxSize: safeComponentConfig.maxSize || 10 * 1024 * 1024, // 10MB maxFiles: safeComponentConfig.maxFiles || 5, - ...safeComponentConfig, } as FileUploadConfig; // 파일 선택 핸들러 @@ -513,7 +513,10 @@ const FileUploadComponent: React.FC = ({ } }, []); - // 파일 업로드 처리 + // 백엔드 multer 제한에 맞춘 1회 요청당 최대 파일 수 + const CHUNK_SIZE = 10; + + // 파일 업로드 처리 (10개 초과 시 자동 분할 업로드) const handleFileUpload = useCallback( async (files: File[]) => { if (!files.length) return; @@ -548,7 +551,17 @@ const FileUploadComponent: React.FC = ({ const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files; setUploadStatus("uploading"); - toast.loading("파일을 업로드하는 중...", { id: "file-upload" }); + + // 분할 업로드 여부 판단 + const totalFiles = filesToUpload.length; + const totalChunks = Math.ceil(totalFiles / CHUNK_SIZE); + const isChunked = totalChunks > 1; + + if (isChunked) { + toast.loading(`파일 업로드 준비 중... (총 ${totalFiles}개, ${totalChunks}회 분할)`, { id: "file-upload" }); + } else { + toast.loading("파일을 업로드하는 중...", { id: "file-upload" }); + } try { // 🔑 레코드 모드 우선 사용 @@ -585,13 +598,11 @@ const FileUploadComponent: React.FC = ({ const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode; // 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용 - // formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시 const finalLinkedTable = effectiveIsRecordMode ? effectiveTableName : (formData?.linkedTable || effectiveTableName); const uploadData = { - // 🎯 formData에서 백엔드 API 설정 가져오기 autoLink: formData?.autoLink || true, linkedTable: finalLinkedTable, recordId: effectiveRecordId || `temp_${component.id}`, @@ -599,143 +610,163 @@ const FileUploadComponent: React.FC = ({ isVirtualFileColumn: formData?.isVirtualFileColumn || true, docType: component.fileConfig?.docType || "DOCUMENT", docTypeName: component.fileConfig?.docTypeName || "일반 문서", - companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달 - // 호환성을 위한 기존 필드들 + companyCode: userCompanyCode, tableName: effectiveTableName, fieldName: effectiveColumnName, - targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가 - // 🆕 레코드 모드 플래그 + targetObjid: targetObjid, isRecordMode: effectiveIsRecordMode, }; - - const response = await uploadFiles({ - files: filesToUpload, - ...uploadData, - }); - if (response.success) { - // FileUploadResponse 타입에 맞게 files 배열 사용 - const fileData = response.files || (response as any).data || []; + // 🔄 파일을 CHUNK_SIZE(10개)씩 나눠서 순차 업로드 + const allNewFiles: any[] = []; + let failedChunks = 0; - if (fileData.length === 0) { - throw new Error("업로드된 파일 데이터를 받지 못했습니다."); + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, totalFiles); + const chunk = filesToUpload.slice(start, end); + + // 분할 업로드 시 진행 상태 토스트 업데이트 + if (isChunked) { + toast.loading( + `업로드 중... ${chunkIndex + 1}/${totalChunks} 배치 (${start + 1}~${end}번째 파일)`, + { id: "file-upload" } + ); } - 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); + const response = await uploadFiles({ + files: chunk, + ...uploadData, + }); + + if (response.success) { + const fileData = response.files || (response as any).data || []; + const chunkFiles = 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, + })); + allNewFiles.push(...chunkFiles); + } else { + console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 실패:`, response); + failedChunks++; + } + } catch (chunkError) { + console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 오류:`, chunkError); + failedChunks++; } + } - // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) - if (typeof window !== "undefined") { - // 전역 파일 상태 업데이트 (레코드별 고유 키 사용) - const globalFileState = (window as any).globalFileState || {}; - const uniqueKey = getUniqueKey(); - globalFileState[uniqueKey] = updatedFiles; - (window as any).globalFileState = globalFileState; + // 모든 배치 처리 완료 후 결과 처리 + if (allNewFiles.length === 0) { + throw new Error("업로드된 파일 데이터를 받지 못했습니다."); + } - // 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용) - GlobalFileManager.registerFiles(newFiles, { - uploadPage: window.location.pathname, + const updatedFiles = [...uploadedFiles, ...allNewFiles]; + + 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(allNewFiles, { + uploadPage: window.location.pathname, + componentId: component.id, + screenId: formData?.screenId, + recordId: recordId, + }); + + const syncEvent = new CustomEvent("globalFileStateChanged", { + detail: { componentId: component.id, - screenId: formData?.screenId, - recordId: recordId, // 🆕 레코드 ID 추가 - }); + eventColumnName: columnName, + uniqueKey: uniqueKey, + recordId: recordId, + files: updatedFiles, + fileCount: updatedFiles.length, + timestamp: Date.now(), + }, + }); + window.dispatchEvent(syncEvent); + } - // 모든 파일 컴포넌트에 동기화 이벤트 발생 - // 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 - const syncEvent = new CustomEvent("globalFileStateChanged", { - detail: { - componentId: component.id, - eventColumnName: columnName, // 🆕 컬럼명 추가 - uniqueKey: uniqueKey, // 🆕 고유 키 추가 - recordId: recordId, // 🆕 레코드 ID 추가 - files: updatedFiles, - fileCount: updatedFiles.length, - timestamp: Date.now(), - }, - }); - window.dispatchEvent(syncEvent); - } - - // 컴포넌트 업데이트 - if (onUpdate) { - const timestamp = Date.now(); - onUpdate({ - uploadedFiles: updatedFiles, - lastFileUpdate: timestamp, - }); - } else { - console.warn("⚠️ onUpdate 콜백이 없습니다!"); - } - - // 🆕 이미지/파일 컬럼에 objid 저장 (formData 업데이트) - if (onFormDataChange && effectiveColumnName) { - // 🎯 이미지/파일 타입 컬럼: 첫 번째 파일의 objid를 저장 (그리드에서 표시용) - // 단일 파일인 경우 단일 값, 복수 파일인 경우 콤마 구분 문자열 - const fileObjids = updatedFiles.map(file => file.objid); - const columnValue = fileConfig.multiple - ? fileObjids.join(',') // 복수 파일: 콤마 구분 - : (fileObjids[0] || ''); // 단일 파일: 첫 번째 파일 ID - - // onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환) - onFormDataChange(effectiveColumnName, columnValue); - } - - // 그리드 파일 상태 새로고침 이벤트 발생 - if (typeof window !== "undefined") { - const refreshEvent = new CustomEvent("refreshFileStatus", { - detail: { - tableName: effectiveTableName, - recordId: effectiveRecordId, - columnName: effectiveColumnName, - targetObjid: targetObjid, - fileCount: updatedFiles.length, - }, - }); - window.dispatchEvent(refreshEvent); - } - - // 컴포넌트 설정 콜백 - if (safeComponentConfig.onFileUpload) { - safeComponentConfig.onFileUpload(newFiles); - } - - // 성공 시 토스트 처리 - setUploadStatus("idle"); - toast.dismiss("file-upload"); - toast.success(`${newFiles.length}개 파일 업로드 완료`); + // 컴포넌트 업데이트 + if (onUpdate) { + const timestamp = Date.now(); + onUpdate({ + uploadedFiles: updatedFiles, + lastFileUpdate: timestamp, + }); } else { - console.error("❌ 파일 업로드 실패:", response); - throw new Error(response.message || (response as any).error || "파일 업로드에 실패했습니다."); + console.warn("⚠️ onUpdate 콜백이 없습니다!"); + } + + // 이미지/파일 컬럼에 objid 저장 (formData 업데이트) + if (onFormDataChange && effectiveColumnName) { + const fileObjids = updatedFiles.map(file => file.objid); + const columnValue = fileConfig.multiple + ? fileObjids.join(',') + : (fileObjids[0] || ''); + onFormDataChange(effectiveColumnName, columnValue); + } + + // 그리드 파일 상태 새로고침 이벤트 발생 + if (typeof window !== "undefined") { + const refreshEvent = new CustomEvent("refreshFileStatus", { + detail: { + tableName: effectiveTableName, + recordId: effectiveRecordId, + columnName: effectiveColumnName, + targetObjid: targetObjid, + fileCount: updatedFiles.length, + }, + }); + window.dispatchEvent(refreshEvent); + } + + // 컴포넌트 설정 콜백 + if (safeComponentConfig.onFileUpload) { + safeComponentConfig.onFileUpload(allNewFiles); + } + + // 성공/부분 성공 토스트 처리 + setUploadStatus("idle"); + toast.dismiss("file-upload"); + + if (failedChunks > 0) { + toast.warning( + `${allNewFiles.length}개 업로드 완료, 일부 파일 실패`, + { description: "일부 파일이 업로드되지 않았습니다. 다시 시도해주세요." } + ); + } else { + toast.success(`${allNewFiles.length}개 파일 업로드 완료`); } } catch (error) { console.error("파일 업로드 오류:", error); @@ -991,19 +1022,26 @@ const FileUploadComponent: React.FC = ({ [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick], ); + // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 값) + const customStyle = component.style || {}; + const hasCustomBorder = !!(customStyle.borderWidth || customStyle.borderColor || customStyle.borderStyle || customStyle.border); + const hasCustomBackground = !!customStyle.backgroundColor; + const hasCustomRadius = !!customStyle.borderRadius; + return (
@@ -1014,15 +1052,15 @@ const FileUploadComponent: React.FC = ({ position: "absolute", top: "-20px", left: "0px", - fontSize: "12px", - color: "rgb(107, 114, 128)", - fontWeight: "400", - background: "transparent !important", - border: "none !important", - boxShadow: "none !important", - outline: "none !important", - padding: "0px !important", - margin: "0px !important" + fontSize: customStyle.labelFontSize || "12px", + color: customStyle.labelColor || "rgb(107, 114, 128)", + fontWeight: customStyle.labelFontWeight || "400", + background: "transparent", + border: "none", + boxShadow: "none", + outline: "none", + padding: "0px", + margin: "0px" }} > {component.label} @@ -1033,7 +1071,13 @@ const FileUploadComponent: React.FC = ({ )}
{/* 대표 이미지 전체 화면 표시 */} {uploadedFiles.length > 0 ? (() => { @@ -1117,7 +1161,7 @@ const FileUploadComponent: React.FC = ({ onFileDelete={handleFileDelete} onFileView={handleFileView} onSetRepresentative={handleSetRepresentative} - config={safeComponentConfig} + config={fileConfig} isDesignMode={isDesignMode} />
diff --git a/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx b/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx index 78157d3e..fe66b458 100644 --- a/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx +++ b/frontend/lib/registry/components/v2-text-display/TextDisplayComponent.tsx @@ -56,16 +56,19 @@ export const TextDisplayComponent: React.FC = ({ // DOM props 필터링 (React 관련 props 제거) const domProps = filterDOMProps(props); + // 🔧 StyleEditor(component.style) 값 우선, 없으면 componentConfig 폴백 + const customStyle = component.style || {}; + // 텍스트 스타일 계산 const textStyle: React.CSSProperties = { - fontSize: componentConfig.fontSize || "14px", - fontWeight: componentConfig.fontWeight || "normal", - color: componentConfig.color || "#212121", - textAlign: componentConfig.textAlign || "left", - backgroundColor: componentConfig.backgroundColor || "transparent", + fontSize: customStyle.fontSize || componentConfig.fontSize || "14px", + fontWeight: customStyle.fontWeight || componentConfig.fontWeight || "normal", + color: customStyle.color || componentConfig.color || "#212121", + textAlign: (customStyle.textAlign || componentConfig.textAlign || "left") as React.CSSProperties["textAlign"], + backgroundColor: customStyle.backgroundColor || componentConfig.backgroundColor || "transparent", padding: componentConfig.padding || "0", - borderRadius: componentConfig.borderRadius || "0", - border: componentConfig.border || "none", + borderRadius: customStyle.borderRadius || componentConfig.borderRadius || "0", + border: customStyle.border || (customStyle.borderWidth ? `${customStyle.borderWidth} ${customStyle.borderStyle || "solid"} ${customStyle.borderColor || "transparent"}` : componentConfig.border || "none"), width: "100%", height: "100%", display: "flex", diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index e1abcb25..358b0df1 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -4984,7 +4984,7 @@ export class ButtonActionExecutor { // visible이 true인 컬럼만 추출 visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName); - // 🎯 column_labels 테이블에서 실제 라벨 가져오기 + // column_labels 테이블에서 실제 라벨 가져오기 try { const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, { params: { page: 1, size: 9999 }, @@ -5021,19 +5021,77 @@ export class ButtonActionExecutor { } }); } - } else { - console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다."); } } } catch (error) { console.error("❌ 화면 레이아웃 조회 실패:", error); } - // 🎨 카테고리 값들 조회 (한 번만) + // Fallback: 레이아웃에서 컬럼 정보를 못 가져온 경우, table_type_columns에서 직접 조회 + // 시스템 컬럼 제외 + 라벨 적용으로 raw 컬럼명 노출 방지 + const SYSTEM_COLUMNS = ["id", "company_code", "created_date", "updated_date", "writer"]; + if ((!visibleColumns || visibleColumns.length === 0) && context.tableName && dataToExport.length > 0) { + console.log("⚠️ 레이아웃에서 컬럼 설정을 찾지 못함 → table_type_columns에서 fallback 조회"); + try { + const { apiClient } = await import("@/lib/api/client"); + const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, { + params: { page: 1, size: 9999 }, + }); + + if (columnsResponse.data?.success && columnsResponse.data?.data) { + let columnData = columnsResponse.data.data; + if (columnData.columns && Array.isArray(columnData.columns)) { + columnData = columnData.columns; + } + + if (Array.isArray(columnData) && columnData.length > 0) { + // visible이 false가 아닌 컬럼만 + 시스템 컬럼 제외 + const filteredCols = columnData.filter((col: any) => { + const colName = (col.column_name || col.columnName || "").toLowerCase(); + if (SYSTEM_COLUMNS.includes(colName)) return false; + if (col.isVisible === false || col.is_visible === false) return false; + return true; + }); + + visibleColumns = filteredCols.map((col: any) => col.column_name || col.columnName); + columnLabels = {}; + filteredCols.forEach((col: any) => { + const colName = col.column_name || col.columnName; + const labelValue = col.column_label || col.label || col.displayName || colName; + if (colName) { + columnLabels![colName] = labelValue; + } + }); + + console.log(`✅ Fallback 컬럼 ${visibleColumns.length}개 로드 완료`); + } + } + } catch (fallbackError) { + console.error("❌ Fallback 컬럼 조회 실패:", fallbackError); + } + } + + // 최종 안전장치: 여전히 컬럼 정보가 없으면 데이터의 키에서 시스템 컬럼만 제외 + if ((!visibleColumns || visibleColumns.length === 0) && dataToExport.length > 0) { + console.log("⚠️ 최종 fallback: 데이터 키에서 시스템 컬럼 제외"); + const allKeys = Object.keys(dataToExport[0]); + visibleColumns = allKeys.filter((key) => { + const lowerKey = key.toLowerCase(); + // 시스템 컬럼 제외 + if (SYSTEM_COLUMNS.includes(lowerKey)) return false; + // _name, _label 등 조인된 보조 필드 제외 + if (lowerKey.endsWith("_name") || lowerKey.endsWith("_label") || lowerKey.endsWith("_value_label")) return false; + return true; + }); + // 라벨이 없으므로 최소한 column_labels 비워두지 않음 (컬럼명 그대로 표시되지만 시스템 컬럼은 제외됨) + if (!columnLabels) { + columnLabels = {}; + } + } + + // 카테고리 값들 조회 (한 번만) const categoryMap: Record> = {}; let categoryColumns: string[] = []; - - // 백엔드에서 카테고리 컬럼 정보 가져오기 if (context.tableName) { try { const { getCategoryColumns, getCategoryValues } = await import("@/lib/api/tableCategoryValue"); @@ -5072,7 +5130,7 @@ export class ButtonActionExecutor { } } - // 🎨 컬럼 필터링 및 라벨 적용 (항상 실행) + // 컬럼 필터링 및 라벨 적용 if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) { dataToExport = dataToExport.map((row: any) => { const filteredRow: Record = {}; @@ -5165,6 +5223,8 @@ export class ButtonActionExecutor { ? config.excelAfterUploadFlows : config.masterDetailExcel?.afterUploadFlows; + // masterDetailExcel 설정이 명시적으로 있을 때만 간단 모드 (디테일만 업로드) + // 설정이 없으면 기본 모드 (마스터+디테일 둘 다 업로드) if (config.masterDetailExcel) { masterDetailExcelConfig = { ...config.masterDetailExcel, @@ -5173,25 +5233,13 @@ export class ButtonActionExecutor { detailTable: relationResponse.data.detailTable, masterKeyColumn: relationResponse.data.masterKeyColumn, detailFkColumn: relationResponse.data.detailFkColumn, - // 채번 규칙 ID 추가 (excelNumberingRuleId를 numberingRuleId로 매핑) - numberingRuleId: config.masterDetailExcel.numberingRuleId || config.excelNumberingRuleId, - // 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선) - afterUploadFlows, - }; - } else { - // 버튼 설정이 없으면 분할 패널 정보만 사용 - masterDetailExcelConfig = { - masterTable: relationResponse.data.masterTable, - detailTable: relationResponse.data.detailTable, - masterKeyColumn: relationResponse.data.masterKeyColumn, - detailFkColumn: relationResponse.data.detailFkColumn, - simpleMode: true, // 기본값으로 간단 모드 사용 - // 채번 규칙 ID 추가 (excelNumberingRuleId 사용) - numberingRuleId: config.excelNumberingRuleId, + // 채번은 ExcelUploadModal에서 마스터 테이블 기반 자동 감지 // 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선) afterUploadFlows, }; } + // masterDetailExcel 설정 없으면 masterDetailExcelConfig는 undefined 유지 + // → ExcelUploadModal에서 기본 모드로 동작 (마스터+디테일 둘 다 매핑/업로드) } } @@ -5233,9 +5281,7 @@ export class ButtonActionExecutor { isMasterDetail, masterDetailRelation, masterDetailExcelConfig, - // 🆕 단일 테이블 채번 설정 - numberingRuleId: config.excelNumberingRuleId, - numberingTargetColumn: config.excelNumberingTargetColumn, + // 채번은 ExcelUploadModal에서 테이블 타입 관리 기반 자동 감지 // 🆕 업로드 후 제어 실행 설정 afterUploadFlows: config.excelAfterUploadFlows, onSuccess: () => {