diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index cddbb73f..f6429a09 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -26,7 +26,9 @@ import { CheckCircle2, ArrowRight, Zap, + Copy, } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { getTableSchema, TableColumn } from "@/lib/api/tableSchema"; @@ -94,6 +96,8 @@ export interface ExcelUploadModalProps { interface ColumnMapping { excelColumn: string; systemColumn: string | null; + // 중복 체크 설정 (해당 컬럼을 중복 체크 키로 사용할지) + checkDuplicate?: boolean; } export const ExcelUploadModal: React.FC = ({ @@ -131,6 +135,9 @@ export const ExcelUploadModal: React.FC = ({ const [excelColumns, setExcelColumns] = useState([]); const [systemColumns, setSystemColumns] = useState([]); const [columnMappings, setColumnMappings] = useState([]); + + // 중복 처리 방법 (전역 설정) + const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip"); // 3단계: 확인 const [isUploading, setIsUploading] = useState(false); @@ -544,6 +551,20 @@ export const ExcelUploadModal: React.FC = ({ ); }; + // 중복 체크 설정 변경 + const handleDuplicateCheckChange = (excelColumn: string, checkDuplicate: boolean) => { + setColumnMappings((prev) => + prev.map((mapping) => + mapping.excelColumn === excelColumn + ? { ...mapping, checkDuplicate } + : mapping + ) + ); + }; + + // 중복 체크 설정된 컬럼 수 + const duplicateCheckCount = columnMappings.filter((m) => m.checkDuplicate && m.systemColumn).length; + // 다음 단계 const handleNext = () => { if (currentStep === 1 && !file) { @@ -707,16 +728,96 @@ export const ExcelUploadModal: React.FC = ({ // 기존 단일 테이블 업로드 로직 let successCount = 0; let failCount = 0; + let skipCount = 0; + let overwriteCount = 0; // 단일 테이블 채번 설정 확인 const hasNumbering = numberingRuleId && numberingTargetColumn; + // 중복 체크 설정 확인 + const duplicateCheckMappings = columnMappings.filter( + (m) => m.checkDuplicate && m.systemColumn + ); + const hasDuplicateCheck = duplicateCheckMappings.length > 0; + + // 중복 체크를 위한 기존 데이터 조회 (중복 체크가 설정된 경우에만) + let existingDataMap: Map = new Map(); + if (hasDuplicateCheck) { + try { + // 중복 체크할 컬럼들의 값 조회 + const checkColumns = duplicateCheckMappings.map((m) => { + let colName = m.systemColumn!; + if (isMasterDetail && colName.includes(".")) { + colName = colName.split(".")[1]; + } + return colName; + }); + + // DynamicFormApi.getTableData 사용 + const existingResponse = await DynamicFormApi.getTableData(tableName, { + page: 1, + pageSize: 10000, + }); + + console.log("📊 중복 체크용 기존 데이터 조회 결과:", existingResponse); + + // getTableData는 { success, data: [...] } 또는 { success, data: { rows: [...] } } 형식 + const rows = existingResponse.data?.rows || existingResponse.data; + if (existingResponse.success && rows && Array.isArray(rows)) { + // 중복 체크 컬럼 값을 키로 하는 맵 생성 + rows.forEach((row: any) => { + const keyParts = checkColumns.map((col) => String(row[col] || "").trim()); + const key = keyParts.join("|||"); + existingDataMap.set(key, row); + }); + console.log(`📊 중복 체크용 기존 데이터 로드: ${existingDataMap.size}건`); + } + } catch (error) { + console.error("중복 체크 데이터 조회 오류:", error); + } + } + for (const row of filteredData) { try { let dataToSave = { ...row }; + let shouldSkip = false; + let shouldUpdate = false; + let existingRow: any = null; - // 채번 적용: 각 행마다 채번 API 호출 - if (hasNumbering && uploadMode === "insert") { + // 중복 체크 + if (hasDuplicateCheck) { + const checkColumns = duplicateCheckMappings.map((m) => { + let colName = m.systemColumn!; + if (isMasterDetail && colName.includes(".")) { + colName = colName.split(".")[1]; + } + return colName; + }); + + const keyParts = checkColumns.map((col) => String(dataToSave[col] || "").trim()); + const key = keyParts.join("|||"); + + if (existingDataMap.has(key)) { + existingRow = existingDataMap.get(key); + // 중복 발견 - 전역 설정에 따라 처리 + if (duplicateAction === "skip") { + shouldSkip = true; + skipCount++; + console.log(`⏭️ 중복으로 건너뛰기: ${key}`); + } else { + shouldUpdate = true; + console.log(`🔄 중복으로 덮어쓰기: ${key}`); + } + } + } + + // 건너뛰기 처리 + if (shouldSkip) { + continue; + } + + // 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만) + if (hasNumbering && uploadMode === "insert" && !shouldUpdate) { try { const { apiClient } = await import("@/lib/api/client"); const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`); @@ -729,7 +830,22 @@ export const ExcelUploadModal: React.FC = ({ } } - if (uploadMode === "insert") { + if (shouldUpdate && existingRow) { + // 덮어쓰기: 기존 데이터 업데이트 + const formData = { + screenId: 0, + tableName, + data: dataToSave, + }; + const result = await DynamicFormApi.updateFormData(existingRow.id, formData); + if (result.success) { + overwriteCount++; + successCount++; + } else { + failCount++; + } + } else if (uploadMode === "insert") { + // 신규 등록 const formData = { screenId: 0, tableName, data: dataToSave }; const result = await DynamicFormApi.saveFormData(formData); if (result.success) { @@ -743,7 +859,7 @@ export const ExcelUploadModal: React.FC = ({ } } - // 🆕 업로드 후 제어 실행 + // 업로드 후 제어 실행 if (afterUploadFlows && afterUploadFlows.length > 0 && successCount > 0) { console.log("🔄 업로드 후 제어 실행:", afterUploadFlows); try { @@ -761,10 +877,24 @@ export const ExcelUploadModal: React.FC = ({ } } - if (successCount > 0) { - toast.success( - `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` - ); + if (successCount > 0 || skipCount > 0) { + // 상세 결과 메시지 생성 + let message = ""; + if (successCount > 0) { + message += `${successCount}개 행 업로드`; + if (overwriteCount > 0) { + message += ` (덮어쓰기 ${overwriteCount}건)`; + } + } + if (skipCount > 0) { + message += message ? `, ` : ""; + message += `중복 건너뛰기 ${skipCount}개`; + } + if (failCount > 0) { + message += ` (실패: ${failCount}개)`; + } + + toast.success(message); // 매핑 템플릿 저장 await saveMappingTemplateInternal(); @@ -825,6 +955,7 @@ export const ExcelUploadModal: React.FC = ({ setExcelColumns([]); setSystemColumns([]); setColumnMappings([]); + setDuplicateAction("skip"); // 🆕 마스터-디테일 모드 초기화 setMasterFieldValues({}); } @@ -1168,17 +1299,18 @@ export const ExcelUploadModal: React.FC = ({ {/* 매핑 리스트 */}
-
+
엑셀 컬럼
시스템 컬럼
+
중복 키
-
+
{columnMappings.map((mapping, index) => (
{mapping.excelColumn} @@ -1220,11 +1352,78 @@ export const ExcelUploadModal: React.FC = ({ ))} + {/* 중복 체크 체크박스 */} +
+ {mapping.systemColumn ? ( + + handleDuplicateCheckChange(mapping.excelColumn, checked as boolean) + } + className="h-4 w-4" + /> + ) : ( + - + )} +
))}
+ {/* 중복 체크 안내 */} + {duplicateCheckCount > 0 ? ( +
+
+
+ +
+

+ 중복 키: {columnMappings + .filter((m) => m.checkDuplicate && m.systemColumn) + .map((m) => { + const col = systemColumns.find((c) => c.name === m.systemColumn); + return col?.label || m.systemColumn; + }) + .join(" + ")} +

+

+ 위 컬럼 값이 모두 일치하는 기존 데이터가 있으면 중복으로 처리합니다. +

+
+
+
+ 중복 시: + +
+
+
+ ) : ( +
+
+ +
+

중복 체크 (선택사항)

+

+ "중복 키" 체크박스를 선택하면 해당 컬럼 값으로 기존 데이터와 비교합니다. + 여러 컬럼을 선택하면 복합 키로 중복을 판단합니다. +

+
+
+
+ )} + {/* 매핑 자동 저장 안내 */} {isAutoMappingLoaded ? (
@@ -1298,6 +1497,11 @@ export const ExcelUploadModal: React.FC = ({

{mapping.excelColumn} →{" "} {col?.label || mapping.systemColumn} + {mapping.checkDuplicate && ( + + (중복 체크: {mapping.duplicateAction === "overwrite" ? "덮어쓰기" : "건너뛰기"}) + + )}

); })} @@ -1307,6 +1511,29 @@ export const ExcelUploadModal: React.FC = ({
+ {/* 중복 체크 요약 */} + {duplicateCheckCount > 0 && ( +
+

중복 체크 설정

+
+

+ 중복 키:{" "} + {columnMappings + .filter((m) => m.checkDuplicate && m.systemColumn) + .map((m) => { + const col = systemColumns.find((c) => c.name === m.systemColumn); + return col?.label || m.systemColumn; + }) + .join(" + ")} +

+

+ 중복 시 처리:{" "} + {duplicateAction === "overwrite" ? "덮어쓰기 (기존 데이터 업데이트)" : "건너뛰기 (해당 행 무시)"} +

+
+
+ )} +
diff --git a/frontend/components/screen/filters/ModernDatePicker.tsx b/frontend/components/screen/filters/ModernDatePicker.tsx index 0a134927..54fdcfed 100644 --- a/frontend/components/screen/filters/ModernDatePicker.tsx +++ b/frontend/components/screen/filters/ModernDatePicker.tsx @@ -84,8 +84,20 @@ export const ModernDatePicker: React.FC = ({ label, value }; const handleConfirm = () => { + // 날짜 순서 자동 정렬 + let finalValue = { ...tempValue }; + + if (finalValue.from && finalValue.to) { + // from이 to보다 나중이면 swap + if (finalValue.from > finalValue.to) { + const temp = finalValue.from; + finalValue.from = finalValue.to; + finalValue.to = temp; + } + } + // 확인 버튼을 눌렀을 때만 onChange 호출 - onChange(tempValue); + onChange(finalValue); setIsOpen(false); setSelectingType("from"); }; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 5fc24920..ca4d57d0 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -380,6 +380,16 @@ export function UniversalFormModalComponent({ const handleBeforeFormSave = (event: Event) => { if (!(event instanceof CustomEvent) || !event.detail?.formData) return; + // 필수값 검증 실행 + const validation = validateRequiredFields(); + if (!validation.valid) { + event.detail.validationFailed = true; + event.detail.validationErrors = validation.missingFields; + toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`); + console.log("[UniversalFormModal] 필수값 검증 실패:", validation.missingFields); + return; // 검증 실패 시 데이터 병합 중단 + } + // 설정에 정의된 필드 columnName 목록 수집 const configuredFields = new Set(); config.sections.forEach((section) => { diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index b8d37c19..32613be4 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -528,6 +528,8 @@ export class ButtonActionExecutor { const beforeSaveEventDetail = { formData: context.formData, skipDefaultSave: false, + validationFailed: false, + validationErrors: [] as string[], }; window.dispatchEvent( new CustomEvent("beforeFormSave", { @@ -540,6 +542,12 @@ export class ButtonActionExecutor { console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData keys:", Object.keys(context.formData || {})); + // 검증 실패 시 저장 중단 + if (beforeSaveEventDetail.validationFailed) { + console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors); + return false; + } + // 🔧 skipDefaultSave 플래그 확인 - SelectedItemsDetailInput 등에서 자체 UPSERT 처리 시 기본 저장 건너뛰기 if (beforeSaveEventDetail.skipDefaultSave) { console.log("🚫 [handleSave] skipDefaultSave=true - 기본 저장 로직 건너뛰기 (컴포넌트에서 자체 처리)");