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:
kjs
2026-03-11 14:16:50 +09:00
parent afd936ff67
commit fa97b361ed
4 changed files with 328 additions and 4 deletions

View File

@@ -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>