feat: enhance TableManagementPage and ExcelUploadModal for improved functionality
- Added handling for unique and nullable column toggles in TableManagementPage, allowing for better column configuration. - Updated ExcelUploadModal to include depth and ancestors in valid options for category values, enhancing the categorization process. - Improved user feedback in ExcelUploadModal by clarifying success messages and ensuring proper handling of duplicate actions. - Refactored category value flattening logic to maintain depth and ancestor information, improving data structure for better usability. These enhancements aim to provide users with a more flexible and intuitive experience when managing table configurations and uploading Excel data.
This commit is contained in:
parent
be0e63e577
commit
2772c2296c
|
|
@ -1586,6 +1586,20 @@ export default function TableManagementPage() {
|
||||||
selectedColumn={selectedColumn}
|
selectedColumn={selectedColumn}
|
||||||
onSelectColumn={setSelectedColumn}
|
onSelectColumn={setSelectedColumn}
|
||||||
onColumnChange={(columnName, field, value) => {
|
onColumnChange={(columnName, field, value) => {
|
||||||
|
if (field === "isUnique") {
|
||||||
|
const currentColumn = columns.find((c) => c.columnName === columnName);
|
||||||
|
if (currentColumn) {
|
||||||
|
handleUniqueToggle(columnName, currentColumn.isUnique || "NO");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "isNullable") {
|
||||||
|
const currentColumn = columns.find((c) => c.columnName === columnName);
|
||||||
|
if (currentColumn) {
|
||||||
|
handleNullableToggle(columnName, currentColumn.isNullable || "YES");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const idx = columns.findIndex((c) => c.columnName === columnName);
|
const idx = columns.findIndex((c) => c.columnName === columnName);
|
||||||
if (idx >= 0) handleColumnChange(idx, field, value);
|
if (idx >= 0) handleColumnChange(idx, field, value);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,11 @@ import {
|
||||||
Zap,
|
Zap,
|
||||||
Copy,
|
Copy,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
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";
|
||||||
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
|
|
@ -100,17 +104,31 @@ interface ColumnMapping {
|
||||||
checkDuplicate?: boolean;
|
checkDuplicate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FlatCategoryValue {
|
||||||
|
valueCode: string;
|
||||||
|
valueLabel: string;
|
||||||
|
depth: number;
|
||||||
|
ancestors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
function flattenCategoryValues(
|
function flattenCategoryValues(
|
||||||
values: Array<{ valueCode: string; valueLabel: string; children?: any[] }>
|
values: Array<{ valueCode: string; valueLabel: string; children?: any[] }>
|
||||||
): Array<{ valueCode: string; valueLabel: string }> {
|
): FlatCategoryValue[] {
|
||||||
const result: Array<{ valueCode: string; valueLabel: string }> = [];
|
const result: FlatCategoryValue[] = [];
|
||||||
const traverse = (items: any[]) => {
|
const traverse = (items: any[], depth: number, ancestors: string[]) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
result.push({ valueCode: item.valueCode, valueLabel: item.valueLabel });
|
result.push({
|
||||||
if (item.children?.length > 0) traverse(item.children);
|
valueCode: item.valueCode,
|
||||||
|
valueLabel: item.valueLabel,
|
||||||
|
depth,
|
||||||
|
ancestors,
|
||||||
|
});
|
||||||
|
if (item.children?.length > 0) {
|
||||||
|
traverse(item.children, depth + 1, [...ancestors, item.valueLabel]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
traverse(values);
|
traverse(values, 0, []);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,6 +168,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
// 중복 처리 방법 (전역 설정)
|
// 중복 처리 방법 (전역 설정)
|
||||||
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
||||||
|
|
||||||
|
// 검증 화면에서 DB 중복 처리 방법 (null이면 미선택 = 업로드 차단)
|
||||||
|
const [dbDuplicateAction, setDbDuplicateAction] = useState<"overwrite" | "skip" | null>(null);
|
||||||
|
|
||||||
// 엑셀 데이터 사전 검증 결과
|
// 엑셀 데이터 사전 검증 결과
|
||||||
const [isDataValidating, setIsDataValidating] = useState(false);
|
const [isDataValidating, setIsDataValidating] = useState(false);
|
||||||
const [validationResult, setValidationResult] = useState<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
|
const [validationResult, setValidationResult] = useState<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
|
||||||
|
|
@ -162,7 +183,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
Record<string, Array<{
|
Record<string, Array<{
|
||||||
invalidValue: string;
|
invalidValue: string;
|
||||||
replacement: string | null;
|
replacement: string | null;
|
||||||
validOptions: Array<{ code: string; label: string }>;
|
validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>;
|
||||||
rowIndices: number[];
|
rowIndices: number[];
|
||||||
}>>
|
}>>
|
||||||
>({});
|
>({});
|
||||||
|
|
@ -723,6 +744,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const options = validValues.map((v) => ({
|
const options = validValues.map((v) => ({
|
||||||
code: v.valueCode,
|
code: v.valueCode,
|
||||||
label: v.valueLabel,
|
label: v.valueLabel,
|
||||||
|
depth: v.depth,
|
||||||
|
ancestors: v.ancestors,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map(
|
mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map(
|
||||||
|
|
@ -795,8 +818,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setDisplayData(newData);
|
setDisplayData(newData);
|
||||||
setShowCategoryValidation(false);
|
setShowCategoryValidation(false);
|
||||||
setCategoryMismatches({});
|
setCategoryMismatches({});
|
||||||
toast.success("카테고리 값이 대체되었습니다.");
|
toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요.");
|
||||||
setCurrentStep(3);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -890,6 +912,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복)
|
// 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복)
|
||||||
|
setDbDuplicateAction(null);
|
||||||
setIsDataValidating(true);
|
setIsDataValidating(true);
|
||||||
try {
|
try {
|
||||||
const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement");
|
const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement");
|
||||||
|
|
@ -1105,9 +1128,33 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const hasNumbering = !!numberingInfo;
|
const hasNumbering = !!numberingInfo;
|
||||||
|
|
||||||
// 중복 체크 설정 확인
|
// 중복 체크 설정 확인
|
||||||
const duplicateCheckMappings = columnMappings.filter(
|
let duplicateCheckMappings = columnMappings.filter(
|
||||||
(m) => m.checkDuplicate && m.systemColumn
|
(m) => m.checkDuplicate && m.systemColumn
|
||||||
);
|
);
|
||||||
|
let effectiveDuplicateAction = duplicateAction;
|
||||||
|
|
||||||
|
// 검증 화면에서 DB 중복 처리 방법을 선택한 경우, 유니크 컬럼을 자동으로 중복 체크에 추가
|
||||||
|
if (dbDuplicateAction && validationResult?.uniqueInDbErrors && validationResult.uniqueInDbErrors.length > 0) {
|
||||||
|
effectiveDuplicateAction = dbDuplicateAction;
|
||||||
|
const uniqueColumns = new Set(validationResult.uniqueInDbErrors.map((e) => e.column));
|
||||||
|
for (const colName of uniqueColumns) {
|
||||||
|
const alreadyAdded = duplicateCheckMappings.some((m) => {
|
||||||
|
const mapped = m.systemColumn?.includes(".") ? m.systemColumn.split(".")[1] : m.systemColumn;
|
||||||
|
return mapped === colName;
|
||||||
|
});
|
||||||
|
if (!alreadyAdded) {
|
||||||
|
const mapping = columnMappings.find((m) => {
|
||||||
|
const mapped = m.systemColumn?.includes(".") ? m.systemColumn.split(".")[1] : m.systemColumn;
|
||||||
|
return mapped === colName;
|
||||||
|
});
|
||||||
|
if (mapping) {
|
||||||
|
duplicateCheckMappings = [...duplicateCheckMappings, { ...mapping, checkDuplicate: true }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`📊 검증 화면 DB 중복 처리: ${dbDuplicateAction}, 체크 컬럼: ${[...uniqueColumns].join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
const hasDuplicateCheck = duplicateCheckMappings.length > 0;
|
const hasDuplicateCheck = duplicateCheckMappings.length > 0;
|
||||||
|
|
||||||
// 중복 체크를 위한 기존 데이터 조회 (중복 체크가 설정된 경우에만)
|
// 중복 체크를 위한 기존 데이터 조회 (중복 체크가 설정된 경우에만)
|
||||||
|
|
@ -1170,7 +1217,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
|
|
||||||
if (existingDataMap.has(key)) {
|
if (existingDataMap.has(key)) {
|
||||||
existingRow = existingDataMap.get(key);
|
existingRow = existingDataMap.get(key);
|
||||||
if (duplicateAction === "skip") {
|
if (effectiveDuplicateAction === "skip") {
|
||||||
shouldSkip = true;
|
shouldSkip = true;
|
||||||
skipCount++;
|
skipCount++;
|
||||||
console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
|
console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
|
||||||
|
|
@ -1352,6 +1399,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setSystemColumns([]);
|
setSystemColumns([]);
|
||||||
setColumnMappings([]);
|
setColumnMappings([]);
|
||||||
setDuplicateAction("skip");
|
setDuplicateAction("skip");
|
||||||
|
setDbDuplicateAction(null);
|
||||||
// 검증 상태 초기화
|
// 검증 상태 초기화
|
||||||
setValidationResult(null);
|
setValidationResult(null);
|
||||||
setIsDataValidating(false);
|
setIsDataValidating(false);
|
||||||
|
|
@ -1366,7 +1414,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={(v) => { if (!showCategoryValidation) onOpenChange(v); }}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1974,12 +2022,50 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
|
|
||||||
{/* DB 기존 데이터 중복 */}
|
{/* DB 기존 데이터 중복 */}
|
||||||
{validationResult.uniqueInDbErrors.length > 0 && (
|
{validationResult.uniqueInDbErrors.length > 0 && (
|
||||||
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
<div className={cn(
|
||||||
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
|
"rounded-md border p-4",
|
||||||
<XCircle className="h-4 w-4" />
|
dbDuplicateAction
|
||||||
DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건)
|
? "border-primary/30 bg-primary/5"
|
||||||
</h3>
|
: "border-destructive bg-destructive/10"
|
||||||
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
|
)}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<h3 className={cn(
|
||||||
|
"flex items-center gap-2 text-sm font-medium sm:text-base",
|
||||||
|
dbDuplicateAction ? "text-primary" : "text-destructive"
|
||||||
|
)}>
|
||||||
|
{dbDuplicateAction ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||||
|
DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건)
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] sm:text-xs",
|
||||||
|
dbDuplicateAction ? "text-primary" : "text-destructive"
|
||||||
|
)}>
|
||||||
|
중복 시:
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={dbDuplicateAction || ""}
|
||||||
|
onValueChange={(value) => setDbDuplicateAction(value as "overwrite" | "skip")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={cn(
|
||||||
|
"h-7 w-[100px] text-[10px] sm:text-xs",
|
||||||
|
dbDuplicateAction
|
||||||
|
? "border-primary/40 bg-white"
|
||||||
|
: "border-destructive/40 bg-white animate-pulse"
|
||||||
|
)}>
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="skip" className="text-xs">건너뛰기</SelectItem>
|
||||||
|
<SelectItem value="overwrite" className="text-xs">덮어쓰기</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] sm:text-xs",
|
||||||
|
dbDuplicateAction ? "text-primary/80" : "text-destructive"
|
||||||
|
)}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const grouped = new Map<string, { value: string; rows: number[] }[]>();
|
const grouped = new Map<string, { value: string; rows: number[] }[]>();
|
||||||
for (const err of validationResult.uniqueInDbErrors) {
|
for (const err of validationResult.uniqueInDbErrors) {
|
||||||
|
|
@ -1993,7 +2079,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
<div key={label}>
|
<div key={label}>
|
||||||
{items.slice(0, 5).map((item, i) => (
|
{items.slice(0, 5).map((item, i) => (
|
||||||
<p key={i}>
|
<p key={i}>
|
||||||
<span className="font-medium">{label}</span> "{item.value}": 행 {item.rows.join(", ")}
|
<span className="font-medium">{label}</span> "{item.value}": 행 {item.rows.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
{items.length > 5 && <p className="font-medium">...외 {items.length - 5}건</p>}
|
{items.length > 5 && <p className="font-medium">...외 {items.length - 5}건</p>}
|
||||||
|
|
@ -2001,6 +2087,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
));
|
));
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
{dbDuplicateAction && (
|
||||||
|
<p className="mt-2 text-[10px] text-primary sm:text-xs font-medium">
|
||||||
|
{dbDuplicateAction === "skip"
|
||||||
|
? "중복 데이터는 건너뛰고 신규 데이터만 업로드합니다."
|
||||||
|
: "중복 데이터는 새 값으로 덮어씁니다."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2114,11 +2207,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
disabled={
|
disabled={
|
||||||
isUploading ||
|
isUploading ||
|
||||||
columnMappings.filter((m) => m.systemColumn).length === 0 ||
|
columnMappings.filter((m) => m.systemColumn).length === 0 ||
|
||||||
(validationResult !== null && !validationResult.isValid)
|
(validationResult !== null && !validationResult.isValid && !(
|
||||||
|
validationResult.notNullErrors.length === 0 &&
|
||||||
|
validationResult.uniqueInExcelErrors.length === 0 &&
|
||||||
|
validationResult.uniqueInDbErrors.length > 0 &&
|
||||||
|
dbDuplicateAction !== null
|
||||||
|
))
|
||||||
}
|
}
|
||||||
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"
|
||||||
>
|
>
|
||||||
{isUploading ? "업로드 중..." : validationResult && !validationResult.isValid ? "검증 실패 - 이전으로 돌아가 수정" : "업로드"}
|
{isUploading ? "업로드 중..." :
|
||||||
|
validationResult && !validationResult.isValid && !(
|
||||||
|
validationResult.notNullErrors.length === 0 &&
|
||||||
|
validationResult.uniqueInExcelErrors.length === 0 &&
|
||||||
|
validationResult.uniqueInDbErrors.length > 0 &&
|
||||||
|
dbDuplicateAction !== null
|
||||||
|
) ? "검증 실패 - 이전으로 돌아가 수정" :
|
||||||
|
dbDuplicateAction === "skip" ? "업로드 (중복 건너뛰기)" :
|
||||||
|
dbDuplicateAction === "overwrite" ? "업로드 (중복 덮어쓰기)" : "업로드"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
@ -2165,33 +2271,63 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
<Select
|
<Popover>
|
||||||
value={item.replacement || ""}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={(val) => {
|
<Button
|
||||||
setCategoryMismatches((prev) => {
|
variant="outline"
|
||||||
const updated = { ...prev };
|
role="combobox"
|
||||||
updated[key] = updated[key].map((it, i) =>
|
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
|
||||||
i === idx ? { ...it, replacement: val } : it
|
>
|
||||||
);
|
<span className="truncate">
|
||||||
return updated;
|
{item.replacement
|
||||||
});
|
? item.validOptions.find((o) => o.code === item.replacement)?.label || item.replacement
|
||||||
}}
|
: "대체 값 선택"}
|
||||||
>
|
</span>
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
<SelectValue placeholder="대체 값 선택" />
|
</Button>
|
||||||
</SelectTrigger>
|
</PopoverTrigger>
|
||||||
<SelectContent>
|
<PopoverContent className="w-[260px] p-0" align="start">
|
||||||
{item.validOptions.map((opt) => (
|
<Command
|
||||||
<SelectItem
|
filter={(value, search) => {
|
||||||
key={opt.code}
|
const opt = item.validOptions.find((o) => o.code === value);
|
||||||
value={opt.code}
|
if (!opt) return 0;
|
||||||
className="text-xs sm:text-sm"
|
const s = search.toLowerCase();
|
||||||
>
|
if (opt.label.toLowerCase().includes(s)) return 1;
|
||||||
{opt.label}
|
if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1;
|
||||||
</SelectItem>
|
return 0;
|
||||||
))}
|
}}
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
<CommandInput placeholder="카테고리 검색..." className="text-xs" />
|
||||||
|
<CommandList className="max-h-52">
|
||||||
|
<CommandEmpty className="py-3 text-xs">찾을 수 없습니다</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{item.validOptions.map((opt) => (
|
||||||
|
<CommandItem
|
||||||
|
key={opt.code}
|
||||||
|
value={opt.code}
|
||||||
|
onSelect={(val) => {
|
||||||
|
setCategoryMismatches((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
updated[key] = updated[key].map((it, i) =>
|
||||||
|
i === idx ? { ...it, replacement: val } : it
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", item.replacement === opt.code ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span style={{ paddingLeft: `${opt.depth * 12}px` }}>
|
||||||
|
{opt.depth > 0 && <span className="mr-1 text-muted-foreground">ㄴ</span>}
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2210,17 +2346,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</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
|
<Button
|
||||||
onClick={applyCategoryReplacements}
|
onClick={applyCategoryReplacements}
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,11 @@ import {
|
||||||
Zap,
|
Zap,
|
||||||
Download,
|
Download,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { importFromExcel, getExcelSheetNames, exportToExcel } from "@/lib/utils/excelExport";
|
import { importFromExcel, getExcelSheetNames, exportToExcel } from "@/lib/utils/excelExport";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
||||||
|
|
@ -51,17 +55,31 @@ interface ColumnMapping {
|
||||||
targetColumn: string | null;
|
targetColumn: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FlatCategoryValue {
|
||||||
|
valueCode: string;
|
||||||
|
valueLabel: string;
|
||||||
|
depth: number;
|
||||||
|
ancestors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
function flattenCategoryValues(
|
function flattenCategoryValues(
|
||||||
values: Array<{ valueCode: string; valueLabel: string; children?: any[] }>
|
values: Array<{ valueCode: string; valueLabel: string; children?: any[] }>
|
||||||
): Array<{ valueCode: string; valueLabel: string }> {
|
): FlatCategoryValue[] {
|
||||||
const result: Array<{ valueCode: string; valueLabel: string }> = [];
|
const result: FlatCategoryValue[] = [];
|
||||||
const traverse = (items: any[]) => {
|
const traverse = (items: any[], depth: number, ancestors: string[]) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
result.push({ valueCode: item.valueCode, valueLabel: item.valueLabel });
|
result.push({
|
||||||
if (item.children?.length > 0) traverse(item.children);
|
valueCode: item.valueCode,
|
||||||
|
valueLabel: item.valueLabel,
|
||||||
|
depth,
|
||||||
|
ancestors,
|
||||||
|
});
|
||||||
|
if (item.children?.length > 0) {
|
||||||
|
traverse(item.children, depth + 1, [...ancestors, item.valueLabel]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
traverse(values);
|
traverse(values, 0, []);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,7 +120,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
Record<string, Array<{
|
Record<string, Array<{
|
||||||
invalidValue: string;
|
invalidValue: string;
|
||||||
replacement: string | null;
|
replacement: string | null;
|
||||||
validOptions: Array<{ code: string; label: string }>;
|
validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>;
|
||||||
rowIndices: number[];
|
rowIndices: number[];
|
||||||
}>>
|
}>>
|
||||||
>({});
|
>({});
|
||||||
|
|
@ -398,6 +416,8 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
const options = validValues.map((v) => ({
|
const options = validValues.map((v) => ({
|
||||||
code: v.valueCode,
|
code: v.valueCode,
|
||||||
label: v.valueLabel,
|
label: v.valueLabel,
|
||||||
|
depth: v.depth,
|
||||||
|
ancestors: v.ancestors,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
|
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
|
||||||
|
|
@ -475,8 +495,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
setDisplayData(newData);
|
setDisplayData(newData);
|
||||||
setShowCategoryValidation(false);
|
setShowCategoryValidation(false);
|
||||||
setCategoryMismatches({});
|
setCategoryMismatches({});
|
||||||
toast.success("카테고리 값이 대체되었습니다.");
|
toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요.");
|
||||||
setCurrentStep(3);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -543,7 +562,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={(v) => { if (!showCategoryValidation) onOpenChange(v); }}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||||
style={{ width: "1000px", height: "700px", minWidth: "700px", minHeight: "500px", maxWidth: "1400px", maxHeight: "900px" }}
|
style={{ width: "1000px", height: "700px", minWidth: "700px", minHeight: "500px", maxWidth: "1400px", maxHeight: "900px" }}
|
||||||
|
|
@ -1020,33 +1039,63 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
<Select
|
<Popover>
|
||||||
value={item.replacement || ""}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={(val) => {
|
<Button
|
||||||
setCategoryMismatches((prev) => {
|
variant="outline"
|
||||||
const updated = { ...prev };
|
role="combobox"
|
||||||
updated[key] = updated[key].map((it, i) =>
|
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
|
||||||
i === idx ? { ...it, replacement: val } : it
|
>
|
||||||
);
|
<span className="truncate">
|
||||||
return updated;
|
{item.replacement
|
||||||
});
|
? item.validOptions.find((o) => o.code === item.replacement)?.label || item.replacement
|
||||||
}}
|
: "대체 값 선택"}
|
||||||
>
|
</span>
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
<SelectValue placeholder="대체 값 선택" />
|
</Button>
|
||||||
</SelectTrigger>
|
</PopoverTrigger>
|
||||||
<SelectContent>
|
<PopoverContent className="w-[260px] p-0" align="start">
|
||||||
{item.validOptions.map((opt) => (
|
<Command
|
||||||
<SelectItem
|
filter={(value, search) => {
|
||||||
key={opt.code}
|
const opt = item.validOptions.find((o) => o.code === value);
|
||||||
value={opt.code}
|
if (!opt) return 0;
|
||||||
className="text-xs sm:text-sm"
|
const s = search.toLowerCase();
|
||||||
>
|
if (opt.label.toLowerCase().includes(s)) return 1;
|
||||||
{opt.label}
|
if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1;
|
||||||
</SelectItem>
|
return 0;
|
||||||
))}
|
}}
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
<CommandInput placeholder="카테고리 검색..." className="text-xs" />
|
||||||
|
<CommandList className="max-h-52">
|
||||||
|
<CommandEmpty className="py-3 text-xs">찾을 수 없습니다</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{item.validOptions.map((opt) => (
|
||||||
|
<CommandItem
|
||||||
|
key={opt.code}
|
||||||
|
value={opt.code}
|
||||||
|
onSelect={(val) => {
|
||||||
|
setCategoryMismatches((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
updated[key] = updated[key].map((it, i) =>
|
||||||
|
i === idx ? { ...it, replacement: val } : it
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", item.replacement === opt.code ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span style={{ paddingLeft: `${opt.depth * 12}px` }}>
|
||||||
|
{opt.depth > 0 && <span className="mr-1 text-muted-foreground">ㄴ</span>}
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1065,17 +1114,6 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</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
|
<Button
|
||||||
onClick={applyCategoryReplacements}
|
onClick={applyCategoryReplacements}
|
||||||
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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue