diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 356d55c3..e3d97088 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -1586,6 +1586,20 @@ export default function TableManagementPage() { selectedColumn={selectedColumn} onSelectColumn={setSelectedColumn} onColumnChange={(columnName, field, value) => { + if (field === "isUnique") { + const currentColumn = columns.find((c) => c.columnName === columnName); + if (currentColumn) { + handleUniqueToggle(columnName, currentColumn.isUnique || "NO"); + } + return; + } + if (field === "isNullable") { + const currentColumn = columns.find((c) => c.columnName === columnName); + if (currentColumn) { + handleNullableToggle(columnName, currentColumn.isNullable || "YES"); + } + return; + } const idx = columns.findIndex((c) => c.columnName === columnName); if (idx >= 0) handleColumnChange(idx, field, value); }} diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 601d44fc..8698b270 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -29,7 +29,11 @@ import { Zap, Copy, Loader2, + Check, + ChevronsUpDown, } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Checkbox } from "@/components/ui/checkbox"; import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; @@ -100,17 +104,31 @@ interface ColumnMapping { checkDuplicate?: boolean; } +interface FlatCategoryValue { + valueCode: string; + valueLabel: string; + depth: number; + ancestors: string[]; +} + function flattenCategoryValues( values: Array<{ valueCode: string; valueLabel: string; children?: any[] }> -): Array<{ valueCode: string; valueLabel: string }> { - const result: Array<{ valueCode: string; valueLabel: string }> = []; - const traverse = (items: any[]) => { +): FlatCategoryValue[] { + const result: FlatCategoryValue[] = []; + const traverse = (items: any[], depth: number, ancestors: string[]) => { for (const item of items) { - result.push({ valueCode: item.valueCode, valueLabel: item.valueLabel }); - if (item.children?.length > 0) traverse(item.children); + result.push({ + valueCode: item.valueCode, + valueLabel: item.valueLabel, + depth, + ancestors, + }); + if (item.children?.length > 0) { + traverse(item.children, depth + 1, [...ancestors, item.valueLabel]); + } } }; - traverse(values); + traverse(values, 0, []); return result; } @@ -150,6 +168,9 @@ export const ExcelUploadModal: React.FC = ({ // 중복 처리 방법 (전역 설정) const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip"); + // 검증 화면에서 DB 중복 처리 방법 (null이면 미선택 = 업로드 차단) + const [dbDuplicateAction, setDbDuplicateAction] = useState<"overwrite" | "skip" | null>(null); + // 엑셀 데이터 사전 검증 결과 const [isDataValidating, setIsDataValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); @@ -162,7 +183,7 @@ export const ExcelUploadModal: React.FC = ({ Record; + validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>; rowIndices: number[]; }>> >({}); @@ -723,6 +744,8 @@ export const ExcelUploadModal: React.FC = ({ const options = validValues.map((v) => ({ code: v.valueCode, label: v.valueLabel, + depth: v.depth, + ancestors: v.ancestors, })); mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map( @@ -795,8 +818,7 @@ export const ExcelUploadModal: React.FC = ({ setDisplayData(newData); setShowCategoryValidation(false); setCategoryMismatches({}); - toast.success("카테고리 값이 대체되었습니다."); - setCurrentStep(3); + toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요."); return true; }; @@ -890,6 +912,7 @@ export const ExcelUploadModal: React.FC = ({ } // 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복) + setDbDuplicateAction(null); setIsDataValidating(true); try { const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement"); @@ -1105,9 +1128,33 @@ export const ExcelUploadModal: React.FC = ({ const hasNumbering = !!numberingInfo; // 중복 체크 설정 확인 - const duplicateCheckMappings = columnMappings.filter( + let duplicateCheckMappings = columnMappings.filter( (m) => m.checkDuplicate && m.systemColumn ); + let effectiveDuplicateAction = duplicateAction; + + // 검증 화면에서 DB 중복 처리 방법을 선택한 경우, 유니크 컬럼을 자동으로 중복 체크에 추가 + if (dbDuplicateAction && validationResult?.uniqueInDbErrors && validationResult.uniqueInDbErrors.length > 0) { + effectiveDuplicateAction = dbDuplicateAction; + const uniqueColumns = new Set(validationResult.uniqueInDbErrors.map((e) => e.column)); + for (const colName of uniqueColumns) { + const alreadyAdded = duplicateCheckMappings.some((m) => { + const mapped = m.systemColumn?.includes(".") ? m.systemColumn.split(".")[1] : m.systemColumn; + return mapped === colName; + }); + if (!alreadyAdded) { + const mapping = columnMappings.find((m) => { + const mapped = m.systemColumn?.includes(".") ? m.systemColumn.split(".")[1] : m.systemColumn; + return mapped === colName; + }); + if (mapping) { + duplicateCheckMappings = [...duplicateCheckMappings, { ...mapping, checkDuplicate: true }]; + } + } + } + console.log(`📊 검증 화면 DB 중복 처리: ${dbDuplicateAction}, 체크 컬럼: ${[...uniqueColumns].join(", ")}`); + } + const hasDuplicateCheck = duplicateCheckMappings.length > 0; // 중복 체크를 위한 기존 데이터 조회 (중복 체크가 설정된 경우에만) @@ -1170,7 +1217,7 @@ export const ExcelUploadModal: React.FC = ({ if (existingDataMap.has(key)) { existingRow = existingDataMap.get(key); - if (duplicateAction === "skip") { + if (effectiveDuplicateAction === "skip") { shouldSkip = true; skipCount++; console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`); @@ -1352,6 +1399,7 @@ export const ExcelUploadModal: React.FC = ({ setSystemColumns([]); setColumnMappings([]); setDuplicateAction("skip"); + setDbDuplicateAction(null); // 검증 상태 초기화 setValidationResult(null); setIsDataValidating(false); @@ -1366,7 +1414,7 @@ export const ExcelUploadModal: React.FC = ({ return ( <> - + { if (!showCategoryValidation) onOpenChange(v); }}> = ({ {/* DB 기존 데이터 중복 */} {validationResult.uniqueInDbErrors.length > 0 && ( -
-

- - DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건) -

-
+
+
+

+ {dbDuplicateAction ? : } + DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건) +

+
+ + 중복 시: + + +
+
+
{(() => { const grouped = new Map(); for (const err of validationResult.uniqueInDbErrors) { @@ -1993,7 +2079,7 @@ export const ExcelUploadModal: React.FC = ({
{items.slice(0, 5).map((item, i) => (

- {label} "{item.value}": 행 {item.rows.join(", ")} + {label} "{item.value}": 행 {item.rows.join(", ")}

))} {items.length > 5 &&

...외 {items.length - 5}건

} @@ -2001,6 +2087,13 @@ export const ExcelUploadModal: React.FC = ({ )); })()}
+ {dbDuplicateAction && ( +

+ {dbDuplicateAction === "skip" + ? "중복 데이터는 건너뛰고 신규 데이터만 업로드합니다." + : "중복 데이터는 새 값으로 덮어씁니다."} +

+ )}
)}
@@ -2114,11 +2207,24 @@ export const ExcelUploadModal: React.FC = ({ disabled={ isUploading || columnMappings.filter((m) => m.systemColumn).length === 0 || - (validationResult !== null && !validationResult.isValid) + (validationResult !== null && !validationResult.isValid && !( + validationResult.notNullErrors.length === 0 && + validationResult.uniqueInExcelErrors.length === 0 && + validationResult.uniqueInDbErrors.length > 0 && + dbDuplicateAction !== null + )) } className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" > - {isUploading ? "업로드 중..." : validationResult && !validationResult.isValid ? "검증 실패 - 이전으로 돌아가 수정" : "업로드"} + {isUploading ? "업로드 중..." : + validationResult && !validationResult.isValid && !( + validationResult.notNullErrors.length === 0 && + validationResult.uniqueInExcelErrors.length === 0 && + validationResult.uniqueInDbErrors.length > 0 && + dbDuplicateAction !== null + ) ? "검증 실패 - 이전으로 돌아가 수정" : + dbDuplicateAction === "skip" ? "업로드 (중복 건너뛰기)" : + dbDuplicateAction === "overwrite" ? "업로드 (중복 덮어쓰기)" : "업로드"} )} @@ -2165,33 +2271,63 @@ export const ExcelUploadModal: React.FC = ({
- + + + + + + { + const opt = item.validOptions.find((o) => o.code === value); + if (!opt) return 0; + const s = search.toLowerCase(); + if (opt.label.toLowerCase().includes(s)) return 1; + if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1; + return 0; + }} + > + + + 찾을 수 없습니다 + + {item.validOptions.map((opt) => ( + { + setCategoryMismatches((prev) => { + const updated = { ...prev }; + updated[key] = updated[key].map((it, i) => + i === idx ? { ...it, replacement: val } : it + ); + return updated; + }); + }} + className="text-xs sm:text-sm" + > + + + {opt.depth > 0 && } + {opt.label} + + + ))} + + + + +
))} @@ -2210,17 +2346,6 @@ export const ExcelUploadModal: React.FC = ({ > 취소 - + + + { + const opt = item.validOptions.find((o) => o.code === value); + if (!opt) return 0; + const s = search.toLowerCase(); + if (opt.label.toLowerCase().includes(s)) return 1; + if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1; + return 0; + }} + > + + + 찾을 수 없습니다 + + {item.validOptions.map((opt) => ( + { + setCategoryMismatches((prev) => { + const updated = { ...prev }; + updated[key] = updated[key].map((it, i) => + i === idx ? { ...it, replacement: val } : it + ); + return updated; + }); + }} + className="text-xs sm:text-sm" + > + + + {opt.depth > 0 && } + {opt.label} + + + ))} + + + + + ))} @@ -1065,17 +1114,6 @@ export const MultiTableExcelUploadModal: React.FC 취소 -