From 262221e300dd2befe9234795a0f0aa310829610f Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 25 Feb 2026 14:42:42 +0900 Subject: [PATCH] fix: Refine ExcelUploadModal and TableListComponent for improved data handling - Updated ExcelUploadModal to automatically generate numbering codes when Excel values are empty, enhancing user experience during data uploads. - Modified TableListComponent to display only the first image in case of multiple images, ensuring clarity in image representation. - Improved data handling logic in TableListComponent to prevent unnecessary processing of string values. --- .../controllers/tableManagementController.ts | 182 +++++++++++++++++- .../src/routes/tableManagementRoutes.ts | 7 + .../src/services/tableManagementService.ts | 95 +++++++++ .../admin/systemMng/tableMngList/page.tsx | 57 ++++-- .../components/common/ExcelUploadModal.tsx | 23 ++- .../table-list/TableListComponent.tsx | 5 +- .../v2-table-list/TableListComponent.tsx | 8 +- 7 files changed, 342 insertions(+), 35 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 5657010f..a74ba8d6 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -939,6 +939,24 @@ export async function addTableData( return; } + // 회사별 UNIQUE 소프트 제약조건 검증 + const uniqueViolations = await tableManagementService.validateUniqueConstraints( + tableName, + data, + companyCode || "*" + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + error: { + code: "UNIQUE_VIOLATION", + details: uniqueViolations, + }, + }); + return; + } + // 데이터 추가 await tableManagementService.addTableData(tableName, data); @@ -1041,6 +1059,26 @@ export async function editTableData( return; } + // 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외) + const excludeId = originalData?.id ? String(originalData.id) : undefined; + const uniqueViolations = await tableManagementService.validateUniqueConstraints( + tableName, + updatedData, + companyCode, + excludeId + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + error: { + code: "UNIQUE_VIOLATION", + details: uniqueViolations, + }, + }); + return; + } + // 데이터 수정 await tableManagementService.editTableData( tableName, @@ -2653,8 +2691,22 @@ export async function toggleTableIndex( logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); if (action === "create") { + let indexColumns = `"${columnName}"`; + + // 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장) + if (indexType === "unique") { + const hasCompanyCode = await query( + `SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + if (hasCompanyCode.length > 0) { + indexColumns = `"company_code", "${columnName}"`; + logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`); + } + } + const uniqueClause = indexType === "unique" ? "UNIQUE " : ""; - const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`; + const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`; logger.info(`인덱스 생성: ${sql}`); await query(sql); } else if (action === "drop") { @@ -2675,15 +2727,45 @@ export async function toggleTableIndex( } catch (error: any) { logger.error("인덱스 토글 오류:", error); - // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내 - const errorMsg = error.message?.includes("duplicate key") - ? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요." - : "인덱스 설정 중 오류가 발생했습니다."; + const errMsg = error.message || ""; + let userMessage = "인덱스 설정 중 오류가 발생했습니다."; + let duplicates: any[] = []; + + // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 + if ( + errMsg.includes("could not create unique index") || + errMsg.includes("duplicate key") + ) { + const { columnName, tableName } = { ...req.params, ...req.body }; + try { + duplicates = await query( + `SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10` + ); + } catch { + try { + duplicates = await query( + `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10` + ); + } catch { /* 중복 조회 실패 시 무시 */ } + } + + const dupDetails = duplicates.length > 0 + ? duplicates.map((d: any) => { + const company = d.company_code ? `[${d.company_code}] ` : ""; + return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`; + }).join(", ") + : ""; + + userMessage = dupDetails + ? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}` + : `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`; + } res.status(500).json({ success: false, - message: errorMsg, - error: error instanceof Error ? error.message : "Unknown error", + message: userMessage, + error: errMsg, + duplicates, }); } } @@ -2776,3 +2858,89 @@ export async function toggleColumnNullable( }); } } + +/** + * UNIQUE 토글 (회사별 소프트 제약조건) + * PUT /api/table-management/tables/:tableName/columns/:columnName/unique + * + * DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다. + * 저장 시 앱 레벨에서 중복 검증을 수행한다. + */ +export async function toggleColumnUnique( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { unique } = req.body; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName || !columnName || typeof unique !== "boolean") { + res.status(400).json({ + success: false, + message: "tableName, columnName, unique(boolean)이 필요합니다.", + }); + return; + } + + const isUniqueValue = unique ? "Y" : "N"; + + if (unique) { + // UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인 + const hasCompanyCode = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + if (hasCompanyCode.length > 0) { + const dupQuery = companyCode === "*" + ? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10` + : `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`; + const dupParams = companyCode === "*" ? [] : [companyCode]; + + const dupResult = await query(dupQuery, dupParams); + + if (dupResult.length > 0) { + const dupDetails = dupResult + .map((d: any) => `"${d[columnName]}" (${d.cnt}건)`) + .join(", "); + + res.status(400).json({ + success: false, + message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`, + }); + return; + } + } + } + + // table_type_columns에 회사별 is_unique 설정 UPSERT + await query( + `INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (table_name, column_name, company_code) + DO UPDATE SET is_unique = $3, updated_date = NOW()`, + [tableName, columnName, isUniqueValue, companyCode] + ); + + logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, { + companyCode, + }); + + res.status(200).json({ + success: true, + message: unique + ? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.` + : `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`, + }); + } catch (error: any) { + logger.error("UNIQUE 토글 오류:", error); + + res.status(500).json({ + success: false, + message: "UNIQUE 설정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index d02a5615..a8964e99 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -32,6 +32,7 @@ import { setTablePrimaryKey, // 🆕 PK 설정 toggleTableIndex, // 🆕 인덱스 토글 toggleColumnNullable, // 🆕 NOT NULL 토글 + toggleColumnUnique, // 🆕 UNIQUE 토글 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex); */ router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable); +/** + * UNIQUE 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/unique + */ +router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique); + /** * 테이블 존재 여부 확인 * GET /api/table-management/tables/:tableName/exists diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index fc83165a..76459ec6 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -204,6 +204,10 @@ export class TableManagementService { THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END ELSE c.is_nullable END as "isNullable", + CASE + WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES' + ELSE 'NO' + END as "isUnique", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -250,6 +254,10 @@ export class TableManagementService { THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END ELSE c.is_nullable END as "isNullable", + CASE + WHEN cl.is_unique = 'Y' THEN 'YES' + ELSE 'NO' + END as "isUnique", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -2534,6 +2542,93 @@ export class TableManagementService { } } + /** + * 회사별 UNIQUE 소프트 제약조건 검증 + * table_type_columns.is_unique = 'Y'인 컬럼에 중복 값이 들어오면 위반 목록을 반환한다. + * @param excludeId 수정 시 자기 자신은 제외 + */ + async validateUniqueConstraints( + tableName: string, + data: Record, + companyCode: string, + excludeId?: string + ): Promise { + try { + // 회사별 설정 우선, 없으면 공통(*) 설정 사용 + let uniqueColumns = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_unique = 'Y' + AND ttc.company_code = $2`, + [tableName, companyCode] + ); + + // 회사별 설정이 없으면 공통 설정 확인 + if (uniqueColumns.length === 0 && companyCode !== "*") { + const globalUnique = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_unique = 'Y' + AND ttc.company_code = '*' + AND NOT EXISTS ( + SELECT 1 FROM table_type_columns ttc2 + WHERE ttc2.table_name = ttc.table_name + AND ttc2.column_name = ttc.column_name + AND ttc2.company_code = $2 + )`, + [tableName, companyCode] + ); + uniqueColumns = globalUnique; + } + + if (uniqueColumns.length === 0) return []; + + const violations: string[] = []; + for (const col of uniqueColumns) { + const value = data[col.column_name]; + if (value === null || value === undefined || value === "") continue; + + // 해당 회사 내에서 같은 값이 이미 존재하는지 확인 + const hasCompanyCode = await query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + let dupQuery: string; + let dupParams: any[]; + + if (hasCompanyCode.length > 0 && companyCode !== "*") { + dupQuery = excludeId + ? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1` + : `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`; + dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode]; + } else { + dupQuery = excludeId + ? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1` + : `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`; + dupParams = excludeId ? [value, excludeId] : [value]; + } + + const dupResult = await query(dupQuery, dupParams); + if (dupResult.length > 0) { + violations.push(`${col.column_label} (${value})`); + } + } + + return violations; + } catch (error) { + logger.error(`UNIQUE 검증 오류: ${tableName}`, error); + return []; + } + } + /** * 테이블에 데이터 추가 * @returns 무시된 컬럼 정보 (디버깅용) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index cf89df73..e1869351 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -63,6 +63,7 @@ interface ColumnTypeInfo { detailSettings: string; description: string; isNullable: string; + isUnique: string; defaultValue?: string; maxLength?: number; numericPrecision?: number; @@ -382,10 +383,11 @@ export default function TableManagementPage() { return { ...col, - inputType: col.inputType || "text", // 기본값: text - numberingRuleId, // 🆕 채번규칙 ID - categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 - hierarchyRole, // 계층구조 역할 + inputType: col.inputType || "text", + isUnique: col.isUnique || "NO", + numberingRuleId, + categoryMenus: col.categoryMenus || [], + hierarchyRole, }; }); @@ -1091,9 +1093,9 @@ export default function TableManagementPage() { } }; - // 인덱스 토글 핸들러 + // 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨) const handleIndexToggle = useCallback( - async (columnName: string, indexType: "index" | "unique", checked: boolean) => { + async (columnName: string, indexType: "index", checked: boolean) => { if (!selectedTable) return; const action = checked ? "create" : "drop"; try { @@ -1122,14 +1124,41 @@ export default function TableManagementPage() { const hasIndex = constraints.indexes.some( (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, ); - const hasUnique = constraints.indexes.some( - (idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, - ); - return { isPk, hasIndex, hasUnique }; + return { isPk, hasIndex }; }, [constraints], ); + // UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴) + const handleUniqueToggle = useCallback( + async (columnName: string, currentIsUnique: string) => { + if (!selectedTable) return; + const isCurrentlyUnique = currentIsUnique === "YES"; + const newUnique = !isCurrentlyUnique; + try { + const response = await apiClient.put( + `/table-management/tables/${selectedTable}/columns/${columnName}/unique`, + { unique: newUnique }, + ); + if (response.data.success) { + toast.success(response.data.message); + setColumns((prev) => + prev.map((col) => + col.columnName === columnName + ? { ...col, isUnique: newUnique ? "YES" : "NO" } + : col, + ), + ); + } else { + toast.error(response.data.message || "UNIQUE 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다."); + } + }, + [selectedTable], + ); + // NOT NULL 토글 핸들러 const handleNullableToggle = useCallback( async (columnName: string, currentIsNullable: string) => { @@ -2029,12 +2058,12 @@ export default function TableManagementPage() { aria-label={`${column.columnName} 인덱스 설정`} /> - {/* UQ 체크박스 */} + {/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
- handleIndexToggle(column.columnName, "unique", checked as boolean) + checked={column.isUnique === "YES"} + onCheckedChange={() => + handleUniqueToggle(column.columnName, column.isUnique) } aria-label={`${column.columnName} 유니크 설정`} /> diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 4797a34a..81b5ed61 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -942,17 +942,22 @@ export const ExcelUploadModal: React.FC = ({ continue; } - // 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용) + // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { - try { - const { apiClient } = await import("@/lib/api/client"); - 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[numberingInfo.columnName] = generatedCode; + const existingValue = dataToSave[numberingInfo.columnName]; + const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== ""; + + if (!hasExcelValue) { + try { + const { apiClient } = await import("@/lib/api/client"); + 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[numberingInfo.columnName] = generatedCode; + } + } catch (numError) { + console.error("채번 오류:", numError); } - } catch (numError) { - console.error("채번 오류:", numError); } } diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 3a3d4e12..2e8ca106 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -4204,9 +4204,10 @@ export const TableListComponent: React.FC = ({ // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; - // 🖼️ 이미지 타입: 작은 썸네일 표시 + // 🖼️ 이미지 타입: 작은 썸네일 표시 (다중 이미지인 경우 대표 이미지 1개만) if (inputType === "image" && value && typeof value === "string") { - const imageUrl = getFullImageUrl(value); + const firstImage = value.includes(",") ? value.split(",")[0].trim() : value; + const imageUrl = getFullImageUrl(firstImage); return ( = React.memo(({ value }) => { React.useEffect(() => { let mounted = true; - const strValue = String(value); + // 다중 이미지인 경우 대표 이미지(첫 번째)만 사용 + const rawValue = String(value); + const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; const isObjid = /^\d+$/.test(strValue); if (isObjid) { @@ -89,8 +91,8 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { style={{ maxWidth: "40px", maxHeight: "40px" }} onClick={(e) => { e.stopPropagation(); - // objid인 경우 preview URL로 열기, 아니면 full URL로 열기 - const strValue = String(value); + const rawValue = String(value); + const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; const isObjid = /^\d+$/.test(strValue); const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue); window.open(openUrl, "_blank");