Merge pull request 'jskim-node' (#407) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/407
This commit is contained in:
commit
202d678e8b
|
|
@ -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
|
||||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
? selectColumns.includes(options.sortBy)
|
||||||
|
? `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
|
||||||
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
|
? selectColumns.includes(options.sortBy)
|
||||||
|
? `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
|
||||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
? selectColumns.includes(options.sortBy)
|
||||||
|
? `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