feat: Enhance Excel upload modals with category validation and error handling
- Added category validation functionality to both ExcelUploadModal and MultiTableExcelUploadModal components, allowing for the detection of invalid category values in uploaded Excel data. - Implemented state management for category validation, including tracking mismatches and user interactions for replacements. - Updated the handleNext function to incorporate category validation checks before proceeding to the next step in the upload process. - Enhanced user feedback with toast notifications for category replacements and validation errors. These changes significantly improve the robustness of the Excel upload process by ensuring data integrity and providing users with clear guidance on category-related issues.
This commit is contained in:
parent
c98b2ccb43
commit
316ce30663
|
|
@ -3463,10 +3463,12 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ORDER BY 절 구성
|
// ORDER BY 절 구성
|
||||||
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용
|
// sortBy가 메인 테이블 컬럼이면 main. 접두사, 조인 별칭이면 접두사 없이 사용
|
||||||
const hasCreatedDateColumn = selectColumns.includes("created_date");
|
const hasCreatedDateColumn = selectColumns.includes("created_date");
|
||||||
const orderBy = options.sortBy
|
const orderBy = options.sortBy
|
||||||
|
? selectColumns.includes(options.sortBy)
|
||||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||||
|
: `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||||
: hasCreatedDateColumn
|
: hasCreatedDateColumn
|
||||||
? `main."created_date" DESC`
|
? `main."created_date" DESC`
|
||||||
: "";
|
: "";
|
||||||
|
|
@ -3710,7 +3712,9 @@ export class TableManagementService {
|
||||||
selectColumns,
|
selectColumns,
|
||||||
"", // WHERE 절은 나중에 추가
|
"", // WHERE 절은 나중에 추가
|
||||||
options.sortBy
|
options.sortBy
|
||||||
|
? selectColumns.includes(options.sortBy)
|
||||||
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
|
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
|
||||||
|
: `"${options.sortBy}" ${options.sortOrder || "ASC"}`
|
||||||
: hasCreatedDateForSearch
|
: hasCreatedDateForSearch
|
||||||
? `main."created_date" DESC`
|
? `main."created_date" DESC`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
@ -3901,7 +3905,9 @@ export class TableManagementService {
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
const hasCreatedDateForOrder = selectColumns.includes("created_date");
|
const hasCreatedDateForOrder = selectColumns.includes("created_date");
|
||||||
const orderBy = options.sortBy
|
const orderBy = options.sortBy
|
||||||
|
? selectColumns.includes(options.sortBy)
|
||||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||||
|
: `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||||
: hasCreatedDateForOrder
|
: hasCreatedDateForOrder
|
||||||
? `main."created_date" DESC`
|
? `main."created_date" DESC`
|
||||||
: "";
|
: "";
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Zap,
|
Zap,
|
||||||
Copy,
|
Copy,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
||||||
|
|
@ -35,6 +36,8 @@ import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
||||||
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
||||||
|
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||||
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
|
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
|
||||||
export interface MasterDetailExcelConfig {
|
export interface MasterDetailExcelConfig {
|
||||||
|
|
@ -133,6 +136,19 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
// 중복 처리 방법 (전역 설정)
|
// 중복 처리 방법 (전역 설정)
|
||||||
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
||||||
|
|
||||||
|
// 카테고리 검증 관련
|
||||||
|
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
||||||
|
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
||||||
|
// { [columnName]: { invalidValue: string, replacement: string | null, validOptions: {code: string, label: string}[], rowIndices: number[] }[] }
|
||||||
|
const [categoryMismatches, setCategoryMismatches] = useState<
|
||||||
|
Record<string, Array<{
|
||||||
|
invalidValue: string;
|
||||||
|
replacement: string | null;
|
||||||
|
validOptions: Array<{ code: string; label: string }>;
|
||||||
|
rowIndices: number[];
|
||||||
|
}>>
|
||||||
|
>({});
|
||||||
|
|
||||||
// 3단계: 확인
|
// 3단계: 확인
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
|
@ -601,8 +617,177 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
// 중복 체크 설정된 컬럼 수
|
// 중복 체크 설정된 컬럼 수
|
||||||
const duplicateCheckCount = columnMappings.filter((m) => m.checkDuplicate && m.systemColumn).length;
|
const duplicateCheckCount = columnMappings.filter((m) => m.checkDuplicate && m.systemColumn).length;
|
||||||
|
|
||||||
|
// 카테고리 컬럼 검증: 엑셀 데이터에서 유효하지 않은 카테고리 값 감지
|
||||||
|
const validateCategoryColumns = async () => {
|
||||||
|
try {
|
||||||
|
setIsCategoryValidating(true);
|
||||||
|
|
||||||
|
const targetTableName = isMasterDetail && masterDetailRelation
|
||||||
|
? masterDetailRelation.detailTable
|
||||||
|
: tableName;
|
||||||
|
|
||||||
|
// 테이블의 카테고리 타입 컬럼 조회
|
||||||
|
const colResponse = await getTableColumns(targetTableName);
|
||||||
|
if (!colResponse.success || !colResponse.data?.columns) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryColumns = colResponse.data.columns.filter(
|
||||||
|
(col: any) => col.inputType === "category"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (categoryColumns.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매핑된 컬럼 중 카테고리 타입인 것 찾기
|
||||||
|
const mappedCategoryColumns: Array<{
|
||||||
|
systemCol: string;
|
||||||
|
excelCol: string;
|
||||||
|
displayName: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const mapping of columnMappings) {
|
||||||
|
if (!mapping.systemColumn) continue;
|
||||||
|
const rawName = mapping.systemColumn.includes(".")
|
||||||
|
? mapping.systemColumn.split(".")[1]
|
||||||
|
: mapping.systemColumn;
|
||||||
|
|
||||||
|
const catCol = categoryColumns.find(
|
||||||
|
(cc: any) => (cc.columnName || cc.column_name) === rawName
|
||||||
|
);
|
||||||
|
if (catCol) {
|
||||||
|
mappedCategoryColumns.push({
|
||||||
|
systemCol: rawName,
|
||||||
|
excelCol: mapping.excelColumn,
|
||||||
|
displayName: catCol.displayName || catCol.display_name || rawName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedCategoryColumns.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 카테고리 컬럼의 유효값 조회 및 엑셀 데이터 검증
|
||||||
|
const mismatches: typeof categoryMismatches = {};
|
||||||
|
|
||||||
|
for (const catCol of mappedCategoryColumns) {
|
||||||
|
const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol);
|
||||||
|
if (!valuesResponse.success || !valuesResponse.data) continue;
|
||||||
|
|
||||||
|
const validValues = valuesResponse.data as Array<{
|
||||||
|
valueCode: string;
|
||||||
|
valueLabel: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// 유효한 코드와 라벨 Set 생성
|
||||||
|
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
||||||
|
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
||||||
|
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
|
||||||
|
|
||||||
|
// 엑셀 데이터에서 유효하지 않은 값 수집
|
||||||
|
const invalidMap = new Map<string, number[]>();
|
||||||
|
|
||||||
|
allData.forEach((row, rowIdx) => {
|
||||||
|
const val = row[catCol.excelCol];
|
||||||
|
if (val === undefined || val === null || String(val).trim() === "") return;
|
||||||
|
const strVal = String(val).trim();
|
||||||
|
|
||||||
|
// 코드 매칭 → 라벨 매칭 → 소문자 라벨 매칭
|
||||||
|
if (validCodes.has(strVal)) return;
|
||||||
|
if (validLabels.has(strVal)) return;
|
||||||
|
if (validLabelsLower.has(strVal.toLowerCase())) return;
|
||||||
|
|
||||||
|
if (!invalidMap.has(strVal)) {
|
||||||
|
invalidMap.set(strVal, []);
|
||||||
|
}
|
||||||
|
invalidMap.get(strVal)!.push(rowIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidMap.size > 0) {
|
||||||
|
const options = validValues.map((v) => ({
|
||||||
|
code: v.valueCode,
|
||||||
|
label: v.valueLabel,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map(
|
||||||
|
([invalidValue, rowIndices]) => ({
|
||||||
|
invalidValue,
|
||||||
|
replacement: null,
|
||||||
|
validOptions: options,
|
||||||
|
rowIndices,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(mismatches).length > 0) {
|
||||||
|
return mismatches;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 검증 실패:", error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsCategoryValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 대체값 선택 후 데이터에 적용
|
||||||
|
const applyCategoryReplacements = () => {
|
||||||
|
// 모든 대체값이 선택되었는지 확인
|
||||||
|
for (const [key, items] of Object.entries(categoryMismatches)) {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.replacement === null) {
|
||||||
|
toast.error("모든 항목의 대체 값을 선택해주세요.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엑셀 컬럼명 → 시스템 컬럼명 매핑 구축
|
||||||
|
const systemToExcelMap = new Map<string, string>();
|
||||||
|
for (const mapping of columnMappings) {
|
||||||
|
if (!mapping.systemColumn) continue;
|
||||||
|
const rawName = mapping.systemColumn.includes(".")
|
||||||
|
? mapping.systemColumn.split(".")[1]
|
||||||
|
: mapping.systemColumn;
|
||||||
|
systemToExcelMap.set(rawName, mapping.excelColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newData = allData.map((row) => ({ ...row }));
|
||||||
|
|
||||||
|
for (const [key, items] of Object.entries(categoryMismatches)) {
|
||||||
|
const systemCol = key.split("|||")[0];
|
||||||
|
const excelCol = systemToExcelMap.get(systemCol);
|
||||||
|
if (!excelCol) continue;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.replacement) continue;
|
||||||
|
// 선택된 대체값의 라벨 찾기
|
||||||
|
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
|
||||||
|
const replacementLabel = selectedOption?.label || item.replacement;
|
||||||
|
|
||||||
|
for (const rowIdx of item.rowIndices) {
|
||||||
|
if (newData[rowIdx]) {
|
||||||
|
newData[rowIdx][excelCol] = replacementLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAllData(newData);
|
||||||
|
setDisplayData(newData);
|
||||||
|
setShowCategoryValidation(false);
|
||||||
|
setCategoryMismatches({});
|
||||||
|
toast.success("카테고리 값이 대체되었습니다.");
|
||||||
|
setCurrentStep(3);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// 다음 단계
|
// 다음 단계
|
||||||
const handleNext = () => {
|
const handleNext = async () => {
|
||||||
if (currentStep === 1 && !file) {
|
if (currentStep === 1 && !file) {
|
||||||
toast.error("파일을 선택해주세요.");
|
toast.error("파일을 선택해주세요.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -655,7 +840,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증
|
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증 + 카테고리 검증
|
||||||
if (currentStep === 2) {
|
if (currentStep === 2) {
|
||||||
// 매핑된 시스템 컬럼 (원본 이름 그대로 + dot 뒤 이름 둘 다 저장)
|
// 매핑된 시스템 컬럼 (원본 이름 그대로 + dot 뒤 이름 둘 다 저장)
|
||||||
const mappedSystemCols = new Set<string>();
|
const mappedSystemCols = new Set<string>();
|
||||||
|
|
@ -681,6 +866,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
toast.error(`필수(NOT NULL) 컬럼이 매핑되지 않았습니다: ${colNames}`);
|
toast.error(`필수(NOT NULL) 컬럼이 매핑되지 않았습니다: ${colNames}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카테고리 컬럼 검증
|
||||||
|
const mismatches = await validateCategoryColumns();
|
||||||
|
if (mismatches) {
|
||||||
|
setCategoryMismatches(mismatches);
|
||||||
|
setShowCategoryValidation(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||||
|
|
@ -1108,12 +1301,17 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setSystemColumns([]);
|
setSystemColumns([]);
|
||||||
setColumnMappings([]);
|
setColumnMappings([]);
|
||||||
setDuplicateAction("skip");
|
setDuplicateAction("skip");
|
||||||
|
// 카테고리 검증 초기화
|
||||||
|
setShowCategoryValidation(false);
|
||||||
|
setCategoryMismatches({});
|
||||||
|
setIsCategoryValidating(false);
|
||||||
// 🆕 마스터-디테일 모드 초기화
|
// 🆕 마스터-디테일 모드 초기화
|
||||||
setMasterFieldValues({});
|
setMasterFieldValues({});
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||||
|
|
@ -1750,10 +1948,17 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
{currentStep < 3 ? (
|
{currentStep < 3 ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={isUploading || (currentStep === 1 && !file)}
|
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
다음
|
{isCategoryValidating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
검증 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"다음"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1769,5 +1974,112 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 카테고리 대체값 선택 다이얼로그 */}
|
||||||
|
<Dialog open={showCategoryValidation} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setShowCategoryValidation(false);
|
||||||
|
setCategoryMismatches({});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
|
<AlertCircle className="h-5 w-5 text-warning" />
|
||||||
|
존재하지 않는 카테고리 값 감지
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
엑셀 데이터에 등록되지 않은 카테고리 값이 있습니다. 각 항목에 대해 대체할 값을 선택해주세요.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[400px] space-y-4 overflow-y-auto pr-1">
|
||||||
|
{Object.entries(categoryMismatches).map(([key, items]) => {
|
||||||
|
const [columnName, displayName] = key.split("|||");
|
||||||
|
return (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">
|
||||||
|
{displayName || columnName}
|
||||||
|
</h4>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${key}-${idx}`}
|
||||||
|
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-md border border-border bg-muted/30 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-medium text-destructive line-through">
|
||||||
|
{item.invalidValue}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{item.rowIndices.length}건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Select
|
||||||
|
value={item.replacement || ""}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
setCategoryMismatches((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
updated[key] = updated[key].map((it, i) =>
|
||||||
|
i === idx ? { ...it, replacement: val } : it
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
||||||
|
<SelectValue placeholder="대체 값 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{item.validOptions.map((opt) => (
|
||||||
|
<SelectItem
|
||||||
|
key={opt.code}
|
||||||
|
value={opt.code}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCategoryValidation(false);
|
||||||
|
setCategoryMismatches({});
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCategoryValidation(false);
|
||||||
|
setCategoryMismatches({});
|
||||||
|
setCurrentStep(3);
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
무시하고 진행
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={applyCategoryReplacements}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ import {
|
||||||
TableChainConfig,
|
TableChainConfig,
|
||||||
uploadMultiTableExcel,
|
uploadMultiTableExcel,
|
||||||
} from "@/lib/api/multiTableExcel";
|
} from "@/lib/api/multiTableExcel";
|
||||||
|
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||||
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
export interface MultiTableExcelUploadModalProps {
|
export interface MultiTableExcelUploadModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -79,6 +81,18 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
// 업로드
|
// 업로드
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리 검증 관련
|
||||||
|
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
||||||
|
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
||||||
|
const [categoryMismatches, setCategoryMismatches] = useState<
|
||||||
|
Record<string, Array<{
|
||||||
|
invalidValue: string;
|
||||||
|
replacement: string | null;
|
||||||
|
validOptions: Array<{ code: string; label: string }>;
|
||||||
|
rowIndices: number[];
|
||||||
|
}>>
|
||||||
|
>({});
|
||||||
|
|
||||||
const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId);
|
const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId);
|
||||||
|
|
||||||
// 선택된 모드에서 활성화되는 컬럼 목록
|
// 선택된 모드에서 활성화되는 컬럼 목록
|
||||||
|
|
@ -302,8 +316,161 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 카테고리 검증: 매핑된 컬럼 중 카테고리 타입인 것의 유효하지 않은 값 감지
|
||||||
|
const validateCategoryColumns = async () => {
|
||||||
|
try {
|
||||||
|
setIsCategoryValidating(true);
|
||||||
|
|
||||||
|
if (!selectedMode) return null;
|
||||||
|
|
||||||
|
const mismatches: typeof categoryMismatches = {};
|
||||||
|
|
||||||
|
// 활성 레벨별로 카테고리 컬럼 검증
|
||||||
|
for (const levelIdx of selectedMode.activeLevels) {
|
||||||
|
const level = config.levels[levelIdx];
|
||||||
|
if (!level) continue;
|
||||||
|
|
||||||
|
// 해당 테이블의 카테고리 타입 컬럼 조회
|
||||||
|
const colResponse = await getTableColumns(level.tableName);
|
||||||
|
if (!colResponse.success || !colResponse.data?.columns) continue;
|
||||||
|
|
||||||
|
const categoryColumns = colResponse.data.columns.filter(
|
||||||
|
(col: any) => col.inputType === "category"
|
||||||
|
);
|
||||||
|
if (categoryColumns.length === 0) continue;
|
||||||
|
|
||||||
|
// 매핑된 컬럼 중 카테고리 타입인 것 찾기
|
||||||
|
for (const catCol of categoryColumns) {
|
||||||
|
const catColName = catCol.columnName || catCol.column_name;
|
||||||
|
const catDisplayName = catCol.displayName || catCol.display_name || catColName;
|
||||||
|
|
||||||
|
// level.columns에서 해당 dbColumn 찾기
|
||||||
|
const levelCol = level.columns.find((lc) => lc.dbColumn === catColName);
|
||||||
|
if (!levelCol) continue;
|
||||||
|
|
||||||
|
// 매핑에서 해당 excelHeader에 연결된 엑셀 컬럼 찾기
|
||||||
|
const mapping = columnMappings.find((m) => m.targetColumn === levelCol.excelHeader);
|
||||||
|
if (!mapping) continue;
|
||||||
|
|
||||||
|
// 유효한 카테고리 값 조회
|
||||||
|
const valuesResponse = await getCategoryValues(level.tableName, catColName);
|
||||||
|
if (!valuesResponse.success || !valuesResponse.data) continue;
|
||||||
|
|
||||||
|
const validValues = valuesResponse.data as Array<{
|
||||||
|
valueCode: string;
|
||||||
|
valueLabel: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
||||||
|
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
||||||
|
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
|
||||||
|
|
||||||
|
// 엑셀 데이터에서 유효하지 않은 값 수집
|
||||||
|
const invalidMap = new Map<string, number[]>();
|
||||||
|
|
||||||
|
allData.forEach((row, rowIdx) => {
|
||||||
|
const val = row[mapping.excelColumn];
|
||||||
|
if (val === undefined || val === null || String(val).trim() === "") return;
|
||||||
|
const strVal = String(val).trim();
|
||||||
|
|
||||||
|
if (validCodes.has(strVal)) return;
|
||||||
|
if (validLabels.has(strVal)) return;
|
||||||
|
if (validLabelsLower.has(strVal.toLowerCase())) return;
|
||||||
|
|
||||||
|
if (!invalidMap.has(strVal)) {
|
||||||
|
invalidMap.set(strVal, []);
|
||||||
|
}
|
||||||
|
invalidMap.get(strVal)!.push(rowIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidMap.size > 0) {
|
||||||
|
const options = validValues.map((v) => ({
|
||||||
|
code: v.valueCode,
|
||||||
|
label: v.valueLabel,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
|
||||||
|
mismatches[key] = Array.from(invalidMap.entries()).map(
|
||||||
|
([invalidValue, rowIndices]) => ({
|
||||||
|
invalidValue,
|
||||||
|
replacement: null,
|
||||||
|
validOptions: options,
|
||||||
|
rowIndices,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(mismatches).length > 0) {
|
||||||
|
return mismatches;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 검증 실패:", error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsCategoryValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 대체값 적용
|
||||||
|
const applyCategoryReplacements = () => {
|
||||||
|
for (const [, items] of Object.entries(categoryMismatches)) {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.replacement === null) {
|
||||||
|
toast.error("모든 항목의 대체 값을 선택해주세요.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시스템 컬럼명 → 엑셀 컬럼명 역매핑 구축
|
||||||
|
const dbColToExcelCol = new Map<string, string>();
|
||||||
|
if (selectedMode) {
|
||||||
|
for (const levelIdx of selectedMode.activeLevels) {
|
||||||
|
const level = config.levels[levelIdx];
|
||||||
|
if (!level) continue;
|
||||||
|
for (const lc of level.columns) {
|
||||||
|
const mapping = columnMappings.find((m) => m.targetColumn === lc.excelHeader);
|
||||||
|
if (mapping) {
|
||||||
|
dbColToExcelCol.set(lc.dbColumn, mapping.excelColumn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newData = allData.map((row) => ({ ...row }));
|
||||||
|
|
||||||
|
for (const [key, items] of Object.entries(categoryMismatches)) {
|
||||||
|
const systemCol = key.split("|||")[0];
|
||||||
|
const excelCol = dbColToExcelCol.get(systemCol);
|
||||||
|
if (!excelCol) continue;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.replacement) continue;
|
||||||
|
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
|
||||||
|
const replacementLabel = selectedOption?.label || item.replacement;
|
||||||
|
|
||||||
|
for (const rowIdx of item.rowIndices) {
|
||||||
|
if (newData[rowIdx]) {
|
||||||
|
newData[rowIdx][excelCol] = replacementLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAllData(newData);
|
||||||
|
setDisplayData(newData);
|
||||||
|
setShowCategoryValidation(false);
|
||||||
|
setCategoryMismatches({});
|
||||||
|
toast.success("카테고리 값이 대체되었습니다.");
|
||||||
|
setCurrentStep(3);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// 다음/이전 단계
|
// 다음/이전 단계
|
||||||
const handleNext = () => {
|
const handleNext = async () => {
|
||||||
if (currentStep === 1) {
|
if (currentStep === 1) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
toast.error("파일을 선택해주세요.");
|
toast.error("파일을 선택해주세요.");
|
||||||
|
|
@ -328,6 +495,14 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`);
|
toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카테고리 컬럼 검증
|
||||||
|
const mismatches = await validateCategoryColumns();
|
||||||
|
if (mismatches) {
|
||||||
|
setCategoryMismatches(mismatches);
|
||||||
|
setShowCategoryValidation(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||||
|
|
@ -349,10 +524,14 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
setDisplayData([]);
|
setDisplayData([]);
|
||||||
setExcelColumns([]);
|
setExcelColumns([]);
|
||||||
setColumnMappings([]);
|
setColumnMappings([]);
|
||||||
|
setShowCategoryValidation(false);
|
||||||
|
setCategoryMismatches({});
|
||||||
|
setIsCategoryValidating(false);
|
||||||
}
|
}
|
||||||
}, [open, config.uploadModes]);
|
}, [open, config.uploadModes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||||
|
|
@ -758,10 +937,17 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
{currentStep < 3 ? (
|
{currentStep < 3 ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={isUploading || (currentStep === 1 && !file)}
|
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
다음
|
{isCategoryValidating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
검증 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"다음"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -782,5 +968,112 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 카테고리 대체값 선택 다이얼로그 */}
|
||||||
|
<Dialog open={showCategoryValidation} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setShowCategoryValidation(false);
|
||||||
|
setCategoryMismatches({});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
|
<AlertCircle className="h-5 w-5 text-warning" />
|
||||||
|
존재하지 않는 카테고리 값 감지
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
엑셀 데이터에 등록되지 않은 카테고리 값이 있습니다. 각 항목에 대해 대체할 값을 선택해주세요.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[400px] space-y-4 overflow-y-auto pr-1">
|
||||||
|
{Object.entries(categoryMismatches).map(([key, items]) => {
|
||||||
|
const [, displayName] = key.split("|||");
|
||||||
|
return (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">
|
||||||
|
{displayName}
|
||||||
|
</h4>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${key}-${idx}`}
|
||||||
|
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-md border border-border bg-muted/30 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-medium text-destructive line-through">
|
||||||
|
{item.invalidValue}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{item.rowIndices.length}건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Select
|
||||||
|
value={item.replacement || ""}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
setCategoryMismatches((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
updated[key] = updated[key].map((it, i) =>
|
||||||
|
i === idx ? { ...it, replacement: val } : it
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
||||||
|
<SelectValue placeholder="대체 값 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{item.validOptions.map((opt) => (
|
||||||
|
<SelectItem
|
||||||
|
key={opt.code}
|
||||||
|
value={opt.code}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCategoryValidation(false);
|
||||||
|
setCategoryMismatches({});
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCategoryValidation(false);
|
||||||
|
setCategoryMismatches({});
|
||||||
|
setCurrentStep(3);
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
무시하고 진행
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={applyCategoryReplacements}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue