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:
kjs 2026-03-17 22:37:13 +09:00
parent be0e63e577
commit 2772c2296c
3 changed files with 284 additions and 107 deletions

View File

@ -1586,6 +1586,20 @@ export default function TableManagementPage() {
selectedColumn={selectedColumn}
onSelectColumn={setSelectedColumn}
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);
if (idx >= 0) handleColumnChange(idx, field, value);
}}

View File

@ -29,7 +29,11 @@ import {
Zap,
Copy,
Loader2,
Check,
ChevronsUpDown,
} 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 { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
@ -100,17 +104,31 @@ interface ColumnMapping {
checkDuplicate?: boolean;
}
interface FlatCategoryValue {
valueCode: string;
valueLabel: string;
depth: number;
ancestors: string[];
}
function flattenCategoryValues(
values: Array<{ valueCode: string; valueLabel: string; children?: any[] }>
): Array<{ valueCode: string; valueLabel: string }> {
const result: Array<{ valueCode: string; valueLabel: string }> = [];
const traverse = (items: any[]) => {
): FlatCategoryValue[] {
const result: FlatCategoryValue[] = [];
const traverse = (items: any[], depth: number, ancestors: string[]) => {
for (const item of items) {
result.push({ valueCode: item.valueCode, valueLabel: item.valueLabel });
if (item.children?.length > 0) traverse(item.children);
result.push({
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;
}
@ -150,6 +168,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
// 중복 처리 방법 (전역 설정)
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
// 검증 화면에서 DB 중복 처리 방법 (null이면 미선택 = 업로드 차단)
const [dbDuplicateAction, setDbDuplicateAction] = useState<"overwrite" | "skip" | null>(null);
// 엑셀 데이터 사전 검증 결과
const [isDataValidating, setIsDataValidating] = useState(false);
const [validationResult, setValidationResult] = useState<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
@ -162,7 +183,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
Record<string, Array<{
invalidValue: string;
replacement: string | null;
validOptions: Array<{ code: string; label: string }>;
validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>;
rowIndices: number[];
}>>
>({});
@ -723,6 +744,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const options = validValues.map((v) => ({
code: v.valueCode,
label: v.valueLabel,
depth: v.depth,
ancestors: v.ancestors,
}));
mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map(
@ -795,8 +818,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setDisplayData(newData);
setShowCategoryValidation(false);
setCategoryMismatches({});
toast.success("카테고리 값이 대체되었습니다.");
setCurrentStep(3);
toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요.");
return true;
};
@ -890,6 +912,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
// 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복)
setDbDuplicateAction(null);
setIsDataValidating(true);
try {
const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement");
@ -1105,9 +1128,33 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const hasNumbering = !!numberingInfo;
// 중복 체크 설정 확인
const duplicateCheckMappings = columnMappings.filter(
let duplicateCheckMappings = columnMappings.filter(
(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;
// 중복 체크를 위한 기존 데이터 조회 (중복 체크가 설정된 경우에만)
@ -1170,7 +1217,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
if (existingDataMap.has(key)) {
existingRow = existingDataMap.get(key);
if (duplicateAction === "skip") {
if (effectiveDuplicateAction === "skip") {
shouldSkip = true;
skipCount++;
console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
@ -1352,6 +1399,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setSystemColumns([]);
setColumnMappings([]);
setDuplicateAction("skip");
setDbDuplicateAction(null);
// 검증 상태 초기화
setValidationResult(null);
setIsDataValidating(false);
@ -1366,7 +1414,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={(v) => { if (!showCategoryValidation) onOpenChange(v); }}>
<DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
style={{
@ -1974,12 +2022,50 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
{/* DB 기존 데이터 중복 */}
{validationResult.uniqueInDbErrors.length > 0 && (
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
<XCircle className="h-4 w-4" />
<div className={cn(
"rounded-md border p-4",
dbDuplicateAction
? "border-primary/30 bg-primary/5"
: "border-destructive bg-destructive/10"
)}>
<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="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
<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[] }[]>();
for (const err of validationResult.uniqueInDbErrors) {
@ -1993,7 +2079,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<div key={label}>
{items.slice(0, 5).map((item, i) => (
<p key={i}>
<span className="font-medium">{label}</span> "{item.value}": {item.rows.join(", ")}
<span className="font-medium">{label}</span> &quot;{item.value}&quot;: {item.rows.join(", ")}
</p>
))}
{items.length > 5 && <p className="font-medium">... {items.length - 5}</p>}
@ -2001,6 +2087,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
));
})()}
</div>
{dbDuplicateAction && (
<p className="mt-2 text-[10px] text-primary sm:text-xs font-medium">
{dbDuplicateAction === "skip"
? "중복 데이터는 건너뛰고 신규 데이터만 업로드합니다."
: "중복 데이터는 새 값으로 덮어씁니다."}
</p>
)}
</div>
)}
</div>
@ -2114,11 +2207,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
disabled={
isUploading ||
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"
>
{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>
)}
</DialogFooter>
@ -2165,9 +2271,41 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</span>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Select
value={item.replacement || ""}
onValueChange={(val) => {
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
>
<span className="truncate">
{item.replacement
? item.validOptions.find((o) => o.code === item.replacement)?.label || item.replacement
: "대체 값 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command
filter={(value, search) => {
const opt = item.validOptions.find((o) => o.code === value);
if (!opt) return 0;
const s = search.toLowerCase();
if (opt.label.toLowerCase().includes(s)) return 1;
if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1;
return 0;
}}
>
<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) =>
@ -2176,22 +2314,20 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
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"
>
<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}
</SelectItem>
</span>
</CommandItem>
))}
</SelectContent>
</Select>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
))}
</div>
@ -2210,17 +2346,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
>
</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"

View File

@ -28,7 +28,11 @@ import {
Zap,
Download,
Loader2,
Check,
ChevronsUpDown,
} 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 { cn } from "@/lib/utils";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
@ -51,17 +55,31 @@ interface ColumnMapping {
targetColumn: string | null;
}
interface FlatCategoryValue {
valueCode: string;
valueLabel: string;
depth: number;
ancestors: string[];
}
function flattenCategoryValues(
values: Array<{ valueCode: string; valueLabel: string; children?: any[] }>
): Array<{ valueCode: string; valueLabel: string }> {
const result: Array<{ valueCode: string; valueLabel: string }> = [];
const traverse = (items: any[]) => {
): FlatCategoryValue[] {
const result: FlatCategoryValue[] = [];
const traverse = (items: any[], depth: number, ancestors: string[]) => {
for (const item of items) {
result.push({ valueCode: item.valueCode, valueLabel: item.valueLabel });
if (item.children?.length > 0) traverse(item.children);
result.push({
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;
}
@ -102,7 +120,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
Record<string, Array<{
invalidValue: string;
replacement: string | null;
validOptions: Array<{ code: string; label: string }>;
validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>;
rowIndices: number[];
}>>
>({});
@ -398,6 +416,8 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
const options = validValues.map((v) => ({
code: v.valueCode,
label: v.valueLabel,
depth: v.depth,
ancestors: v.ancestors,
}));
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
@ -475,8 +495,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
setDisplayData(newData);
setShowCategoryValidation(false);
setCategoryMismatches({});
toast.success("카테고리 값이 대체되었습니다.");
setCurrentStep(3);
toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요.");
return true;
};
@ -543,7 +562,7 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={(v) => { if (!showCategoryValidation) onOpenChange(v); }}>
<DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
style={{ width: "1000px", height: "700px", minWidth: "700px", minHeight: "500px", maxWidth: "1400px", maxHeight: "900px" }}
@ -1020,9 +1039,41 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
</span>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Select
value={item.replacement || ""}
onValueChange={(val) => {
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
>
<span className="truncate">
{item.replacement
? item.validOptions.find((o) => o.code === item.replacement)?.label || item.replacement
: "대체 값 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command
filter={(value, search) => {
const opt = item.validOptions.find((o) => o.code === value);
if (!opt) return 0;
const s = search.toLowerCase();
if (opt.label.toLowerCase().includes(s)) return 1;
if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1;
return 0;
}}
>
<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) =>
@ -1031,22 +1082,20 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
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"
>
<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}
</SelectItem>
</span>
</CommandItem>
))}
</SelectContent>
</Select>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
))}
</div>
@ -1065,17 +1114,6 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
>
</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"