feat: add Excel data validation functionality
- Implemented a new API endpoint for validating Excel data before upload, ensuring that required fields are not null and that unique constraints are respected. - Added frontend integration to handle validation results, displaying errors for missing required fields and duplicates within the Excel file and against existing database records. - Enhanced user experience by providing immediate feedback on data validity during the upload process. Made-with: Cursor
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
FileSpreadsheet,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
Copy,
|
||||
@@ -136,6 +137,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
// 중복 처리 방법 (전역 설정)
|
||||
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
||||
|
||||
// 엑셀 데이터 사전 검증 결과
|
||||
const [isDataValidating, setIsDataValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
|
||||
|
||||
// 카테고리 검증 관련
|
||||
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
||||
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
||||
@@ -874,6 +879,43 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
setShowCategoryValidation(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복)
|
||||
setIsDataValidating(true);
|
||||
try {
|
||||
const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement");
|
||||
|
||||
// 매핑된 데이터 구성
|
||||
const mappedForValidation = allData.map((row) => {
|
||||
const mapped: Record<string, any> = {};
|
||||
columnMappings.forEach((m) => {
|
||||
if (m.systemColumn) {
|
||||
let colName = m.systemColumn;
|
||||
if (isMasterDetail && colName.includes(".")) {
|
||||
colName = colName.split(".")[1];
|
||||
}
|
||||
mapped[colName] = row[m.excelColumn];
|
||||
}
|
||||
});
|
||||
return mapped;
|
||||
}).filter((row) => Object.values(row).some((v) => v !== null && v !== undefined && String(v).trim() !== ""));
|
||||
|
||||
if (mappedForValidation.length > 0) {
|
||||
const result = await validateExcel(tableName, mappedForValidation);
|
||||
if (result.success && result.data) {
|
||||
setValidationResult(result.data);
|
||||
} else {
|
||||
setValidationResult(null);
|
||||
}
|
||||
} else {
|
||||
setValidationResult(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("데이터 사전 검증 실패 (무시):", err);
|
||||
setValidationResult(null);
|
||||
} finally {
|
||||
setIsDataValidating(false);
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
@@ -1301,6 +1343,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
setSystemColumns([]);
|
||||
setColumnMappings([]);
|
||||
setDuplicateAction("skip");
|
||||
// 검증 상태 초기화
|
||||
setValidationResult(null);
|
||||
setIsDataValidating(false);
|
||||
// 카테고리 검증 초기화
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
@@ -1870,6 +1915,100 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 검증 결과 */}
|
||||
{validationResult && !validationResult.isValid && (
|
||||
<div className="space-y-3">
|
||||
{/* NOT NULL 에러 */}
|
||||
{validationResult.notNullErrors.length > 0 && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
|
||||
<XCircle className="h-4 w-4" />
|
||||
필수값 누락 ({validationResult.notNullErrors.length}건)
|
||||
</h3>
|
||||
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
|
||||
{(() => {
|
||||
const grouped = new Map<string, number[]>();
|
||||
for (const err of validationResult.notNullErrors) {
|
||||
const key = err.label;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key)!.push(err.row);
|
||||
}
|
||||
return Array.from(grouped).map(([label, rows]) => (
|
||||
<p key={label}>
|
||||
<span className="font-medium">{label}</span>: {rows.length > 5 ? `행 ${rows.slice(0, 5).join(", ")} 외 ${rows.length - 5}건` : `행 ${rows.join(", ")}`}
|
||||
</p>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 엑셀 내부 중복 */}
|
||||
{validationResult.uniqueInExcelErrors.length > 0 && (
|
||||
<div className="rounded-md border border-warning bg-warning/10 p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-warning sm:text-base">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
엑셀 내 중복 ({validationResult.uniqueInExcelErrors.length}건)
|
||||
</h3>
|
||||
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-warning sm:text-xs">
|
||||
{validationResult.uniqueInExcelErrors.slice(0, 10).map((err, i) => (
|
||||
<p key={i}>
|
||||
<span className="font-medium">{err.label}</span> "{err.value}": 행 {err.rows.join(", ")}
|
||||
</p>
|
||||
))}
|
||||
{validationResult.uniqueInExcelErrors.length > 10 && (
|
||||
<p className="font-medium">...외 {validationResult.uniqueInExcelErrors.length - 10}건</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DB 기존 데이터 중복 */}
|
||||
{validationResult.uniqueInDbErrors.length > 0 && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
|
||||
<XCircle className="h-4 w-4" />
|
||||
DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건)
|
||||
</h3>
|
||||
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
|
||||
{(() => {
|
||||
const grouped = new Map<string, { value: string; rows: number[] }[]>();
|
||||
for (const err of validationResult.uniqueInDbErrors) {
|
||||
const key = err.label;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
const existing = grouped.get(key)!.find((e) => e.value === err.value);
|
||||
if (existing) existing.rows.push(err.row);
|
||||
else grouped.get(key)!.push({ value: err.value, rows: [err.row] });
|
||||
}
|
||||
return Array.from(grouped).map(([label, items]) => (
|
||||
<div key={label}>
|
||||
{items.slice(0, 5).map((item, i) => (
|
||||
<p key={i}>
|
||||
<span className="font-medium">{label}</span> "{item.value}": 행 {item.rows.join(", ")}
|
||||
</p>
|
||||
))}
|
||||
{items.length > 5 && <p className="font-medium">...외 {items.length - 5}건</p>}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationResult?.isValid && (
|
||||
<div className="rounded-md border border-success bg-success/10 p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-success sm:text-base">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
데이터 검증 통과
|
||||
</h3>
|
||||
<p className="mt-1 text-[10px] text-success sm:text-xs">
|
||||
필수값 및 중복 검사를 통과했습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/50 p-4">
|
||||
<h3 className="text-sm font-medium sm:text-base">컬럼 매핑</h3>
|
||||
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
@@ -1948,10 +2087,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
{currentStep < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
|
||||
disabled={isUploading || isCategoryValidating || isDataValidating || (currentStep === 1 && !file)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isCategoryValidating ? (
|
||||
{isCategoryValidating || isDataValidating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
검증 중...
|
||||
@@ -1964,11 +2103,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={
|
||||
isUploading || columnMappings.filter((m) => m.systemColumn).length === 0
|
||||
isUploading ||
|
||||
columnMappings.filter((m) => m.systemColumn).length === 0 ||
|
||||
(validationResult !== null && !validationResult.isValid)
|
||||
}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isUploading ? "업로드 중..." : "업로드"}
|
||||
{isUploading ? "업로드 중..." : validationResult && !validationResult.isValid ? "검증 실패 - 이전으로 돌아가 수정" : "업로드"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
Reference in New Issue
Block a user