Files
vexplor/frontend/components/common/MultiTableExcelUploadModal.tsx
kjs c618283306 feat: Refactor work process and item inspection logic
- Updated SQL queries in `popProductionController` to separate work order process and result handling, ensuring batch_id is now managed in the work_order_process_result table.
- Enhanced `workInstructionController` to include id generation for work items and details, preventing NULL values during insertion.
- Implemented case-insensitive search functionality across various services, improving data retrieval accuracy.
- Added sorting functionality in the item inspection page, allowing users to sort by different columns with visual indicators for sort direction.

This refactor aims to improve data integrity and user experience across the production and inspection workflows.
2026-04-23 17:36:04 +09:00

1161 lines
43 KiB
TypeScript

"use client";
import React, { useState, useRef, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import {
Upload,
FileSpreadsheet,
AlertCircle,
CheckCircle2,
ArrowRight,
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 } from "@/lib/utils/excelExport";
import { cn } from "@/lib/utils";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
import {
TableChainConfig,
uploadMultiTableExcel,
} from "@/lib/api/multiTableExcel";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { getTableColumns } from "@/lib/api/tableManagement";
export interface MultiTableExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: TableChainConfig;
onSuccess?: () => void;
}
interface ColumnMapping {
excelColumn: string;
targetColumn: string | null;
}
interface FlatCategoryValue {
valueCode: string;
valueLabel: string;
depth: number;
ancestors: string[];
}
function flattenCategoryValues(
values: Array<{ valueCode: string; valueLabel: string; children?: 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,
depth,
ancestors,
});
if (item.children?.length > 0) {
traverse(item.children, depth + 1, [...ancestors, item.valueLabel]);
}
}
};
traverse(values, 0, []);
return result;
}
export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProps> = ({
open,
onOpenChange,
config,
onSuccess,
}) => {
// 스텝: 1=모드선택+파일, 2=컬럼매핑, 3=확인
const [currentStep, setCurrentStep] = useState(1);
// 모드 선택
const [selectedModeId, setSelectedModeId] = useState<string>(
config.uploadModes[0]?.id || ""
);
// 파일
const [file, setFile] = useState<File | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [selectedSheet, setSelectedSheet] = useState("");
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [allData, setAllData] = useState<Record<string, any>[]>([]);
const [displayData, setDisplayData] = useState<Record<string, any>[]>([]);
const [excelColumns, setExcelColumns] = useState<string[]>([]);
// 매핑
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
// 업로드
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; depth: number; ancestors: string[] }>;
rowIndices: number[];
}>>
>({});
const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId);
// 선택된 모드에서 활성화되는 컬럼 목록
const activeColumns = React.useMemo(() => {
if (!selectedMode) return [];
const cols: Array<{ dbColumn: string; excelHeader: string; required: boolean; levelLabel: string }> = [];
for (const levelIdx of selectedMode.activeLevels) {
const level = config.levels[levelIdx];
if (!level) continue;
for (const col of level.columns) {
cols.push({
...col,
levelLabel: level.label,
});
}
}
return cols;
}, [selectedMode, config.levels]);
// 템플릿 다운로드 (필수 헤더는 빨강 폰트)
const handleDownloadTemplate = async () => {
if (!selectedMode) return;
try {
const headers: string[] = [];
const requiredFlags: boolean[] = [];
for (const levelIdx of selectedMode.activeLevels) {
const level = config.levels[levelIdx];
if (!level) continue;
for (const col of level.columns) {
headers.push(col.excelHeader);
requiredFlags.push(!!col.required);
}
}
const ExcelJS = (await import("exceljs")).default;
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet("Sheet1");
headers.forEach((_, i) => {
ws.getColumn(i + 1).width = 18;
});
const headerRow = ws.addRow(headers);
headerRow.height = 24;
headerRow.eachCell((cell, colNumber) => {
const isRequired = requiredFlags[colNumber - 1];
cell.font = {
bold: true,
size: 10,
color: { argb: isRequired ? "FFDC2626" : "FF1E293B" },
};
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF1F5F9" } };
cell.border = { bottom: { style: "medium", color: { argb: "FF94A3B8" } } };
cell.alignment = { vertical: "middle", horizontal: "center" };
});
// 빈 행 1줄 (사용자가 입력할 수 있도록)
ws.addRow(headers.map(() => ""));
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${config.name}_${selectedMode.label}_템플릿.xlsx`;
a.click();
URL.revokeObjectURL(url);
toast.success("템플릿 파일이 다운로드되었습니다.");
} catch (err) {
console.error("템플릿 다운로드 실패:", err);
toast.error("템플릿 다운로드에 실패했습니다.");
}
};
// 파일 처리
const processFile = async (selectedFile: File) => {
const ext = selectedFile.name.split(".").pop()?.toLowerCase();
if (!["xlsx", "xls", "csv"].includes(ext || "")) {
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
return;
}
setFile(selectedFile);
try {
const sheets = await getExcelSheetNames(selectedFile);
setSheetNames(sheets);
setSelectedSheet(sheets[0] || "");
const data = await importFromExcel(selectedFile, sheets[0]);
setAllData(data);
setDisplayData(data);
if (data.length > 0) {
setExcelColumns(Object.keys(data[0]));
}
toast.success(`파일 선택 완료: ${selectedFile.name}`);
} catch (error) {
console.error("파일 읽기 오류:", error);
toast.error("파일을 읽는 중 오류가 발생했습니다.");
setFile(null);
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) await processFile(selectedFile);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) await processFile(droppedFile);
};
const handleSheetChange = async (sheetName: string) => {
setSelectedSheet(sheetName);
if (!file) return;
try {
const data = await importFromExcel(file, sheetName);
setAllData(data);
setDisplayData(data);
if (data.length > 0) {
setExcelColumns(Object.keys(data[0]));
}
} catch (error) {
console.error("시트 읽기 오류:", error);
toast.error("시트를 읽는 중 오류가 발생했습니다.");
}
};
// 2단계 진입 시 자동 매핑 시도
useEffect(() => {
if (currentStep === 2 && excelColumns.length > 0) {
performAutoMapping();
}
}, [currentStep]);
const performAutoMapping = () => {
const newMappings: ColumnMapping[] = excelColumns.map((excelCol) => {
const normalizedExcel = excelCol.toLowerCase().trim();
const matched = activeColumns.find((ac) => {
return (
ac.excelHeader.toLowerCase().trim() === normalizedExcel ||
ac.dbColumn.toLowerCase().trim() === normalizedExcel
);
});
return {
excelColumn: excelCol,
targetColumn: matched ? matched.excelHeader : null,
};
});
setColumnMappings(newMappings);
const matchedCount = newMappings.filter((m) => m.targetColumn).length;
if (matchedCount > 0) {
toast.success(`${matchedCount}개 컬럼이 자동 매핑되었습니다.`);
}
};
const handleMappingChange = (excelColumn: string, targetColumn: string | null) => {
setColumnMappings((prev) =>
prev.map((m) =>
m.excelColumn === excelColumn ? { ...m, targetColumn } : m
)
);
};
// 업로드 실행
const handleUpload = async () => {
if (!file || !selectedMode) return;
setIsUploading(true);
try {
// 엑셀 데이터를 excelHeader 기준으로 변환
const mappedRows = allData.map((row) => {
const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => {
if (mapping.targetColumn) {
mappedRow[mapping.targetColumn] = row[mapping.excelColumn];
}
});
return mappedRow;
});
// 빈 행 필터링
const filteredRows = mappedRows.filter((row) =>
Object.values(row).some(
(v) => v !== undefined && v !== null && (typeof v !== "string" || v.trim() !== "")
)
);
console.log(`다중 테이블 업로드: ${filteredRows.length}`);
const result = await uploadMultiTableExcel({
config,
modeId: selectedModeId,
rows: filteredRows,
});
if (result.success && result.data) {
const { results, errors } = result.data;
const summaryParts = results
.filter((r) => r.inserted + r.updated > 0)
.map((r) => {
const parts: string[] = [];
if (r.inserted > 0) parts.push(`신규 ${r.inserted}`);
if (r.updated > 0) parts.push(`수정 ${r.updated}`);
return `${r.tableName}: ${parts.join(", ")}`;
});
const msg = summaryParts.join(" / ");
const errorMsg = errors.length > 0 ? ` (오류: ${errors.length}건)` : "";
toast.success(`업로드 완료: ${msg}${errorMsg}`);
if (errors.length > 0) {
console.warn("업로드 오류 목록:", errors);
}
onSuccess?.();
onOpenChange(false);
} else {
toast.error(result.message || "업로드에 실패했습니다.");
}
} catch (error) {
console.error("다중 테이블 업로드 실패:", error);
toast.error("업로드 중 오류가 발생했습니다.");
} finally {
setIsUploading(false);
}
};
// 카테고리 검증: 매핑된 컬럼 중 카테고리 타입인 것의 유효하지 않은 값 감지
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 = flattenCategoryValues(valuesResponse.data as any[]);
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,
depth: v.depth,
ancestors: v.ancestors,
}));
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("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요.");
return true;
};
// 다음/이전 단계
const handleNext = async () => {
if (currentStep === 1) {
if (!file) {
toast.error("파일을 선택해주세요.");
return;
}
if (displayData.length === 0) {
toast.error("데이터가 없습니다.");
return;
}
}
if (currentStep === 2) {
// 필수 컬럼 매핑 확인
const mappedTargets = new Set(
columnMappings.filter((m) => m.targetColumn).map((m) => m.targetColumn)
);
const unmappedRequired = activeColumns
.filter((ac) => ac.required && !mappedTargets.has(ac.excelHeader))
.map((ac) => `${ac.excelHeader}`);
if (unmappedRequired.length > 0) {
toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`);
return;
}
// 카테고리 컬럼 검증
const mismatches = await validateCategoryColumns();
if (mismatches) {
setCategoryMismatches(mismatches);
setShowCategoryValidation(true);
return;
}
}
setCurrentStep((prev) => Math.min(prev + 1, 3));
};
const handlePrevious = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
// 모달 닫기 시 초기화
useEffect(() => {
if (!open) {
setCurrentStep(1);
setSelectedModeId(config.uploadModes[0]?.id || "");
setFile(null);
setSheetNames([]);
setSelectedSheet("");
setAllData([]);
setDisplayData([]);
setExcelColumns([]);
setColumnMappings([]);
setShowCategoryValidation(false);
setCategoryMismatches({});
setIsCategoryValidating(false);
}
}, [open, config.uploadModes]);
return (
<>
<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" }}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FileSpreadsheet className="h-5 w-5" />
{config.name} -
<span className="ml-2 rounded bg-indigo-100 px-2 py-0.5 text-xs font-normal text-indigo-700">
</span>
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{config.description}
</DialogDescription>
</DialogHeader>
{/* 스텝 인디케이터 */}
<div className="flex items-center justify-between">
{[
{ num: 1, label: "모드 선택 / 파일" },
{ num: 2, label: "컬럼 매핑" },
{ num: 3, label: "확인" },
].map((step, index) => (
<React.Fragment key={step.num}>
<div className="flex flex-col items-center gap-1">
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors sm:h-10 sm:w-10",
currentStep === step.num
? "bg-primary text-primary-foreground"
: currentStep > step.num
? "bg-success text-white"
: "bg-muted text-muted-foreground"
)}
>
{currentStep > step.num ? (
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
) : (
step.num
)}
</div>
<span
className={cn(
"text-[10px] font-medium sm:text-xs",
currentStep === step.num ? "text-primary" : "text-muted-foreground"
)}
>
{step.label}
</span>
</div>
{index < 2 && (
<div
className={cn(
"h-0.5 flex-1 transition-colors",
currentStep > step.num ? "bg-success" : "bg-muted"
)}
/>
)}
</React.Fragment>
))}
</div>
{/* 스텝별 컨텐츠 */}
<div className="max-h-[calc(95vh-200px)] space-y-4 overflow-y-auto">
{/* 1단계: 모드 선택 + 파일 선택 */}
{currentStep === 1 && (
<div className="space-y-4">
{/* 업로드 모드 선택 */}
<div>
<Label className="text-xs font-medium sm:text-sm"> *</Label>
<div className="mt-2 grid gap-2 sm:grid-cols-3">
{config.uploadModes.map((mode) => (
<button
key={mode.id}
type="button"
onClick={() => {
setSelectedModeId(mode.id);
setFile(null);
setAllData([]);
setDisplayData([]);
setExcelColumns([]);
}}
className={cn(
"rounded-lg border p-3 text-left transition-all",
selectedModeId === mode.id
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<p className="text-xs font-semibold sm:text-sm">{mode.label}</p>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
{mode.description}
</p>
</button>
))}
</div>
</div>
{/* 템플릿 다운로드 */}
<div className="flex items-center justify-between rounded-md border border-muted bg-muted/30 p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:text-sm">
<Download className="h-4 w-4" />
<span> </span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleDownloadTemplate}
className="h-8 text-xs sm:text-sm"
>
<Download className="mr-1 h-3 w-3" />
릿
</Button>
</div>
{/* 파일 선택 */}
<div>
<Label htmlFor="multi-file-upload" className="text-xs sm:text-sm">
*
</Label>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={cn(
"mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
isDragOver
? "border-primary bg-primary/5"
: file
? "border-green-500 bg-green-50"
: "border-muted-foreground/25 hover:border-primary hover:bg-muted/50"
)}
>
{file ? (
<div className="flex items-center gap-3">
<FileSpreadsheet className="h-8 w-8 text-green-600" />
<div>
<p className="text-sm font-medium text-green-700">{file.name}</p>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
) : (
<>
<Upload
className={cn(
"mb-2 h-8 w-8",
isDragOver ? "text-primary" : "text-muted-foreground"
)}
/>
<p
className={cn(
"text-sm font-medium",
isDragOver ? "text-primary" : "text-muted-foreground"
)}
>
{isDragOver ? "파일을 놓으세요" : "파일을 드래그하거나 클릭하여 선택"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
형식: .xlsx, .xls, .csv
</p>
</>
)}
<input
ref={fileInputRef}
id="multi-file-upload"
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div>
{/* 미리보기 */}
{file && displayData.length > 0 && (
<>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground sm:text-sm">:</Label>
<Select value={selectedSheet} onValueChange={handleSheetChange}>
<SelectTrigger className="h-8 w-[140px] text-xs sm:h-9 sm:w-[180px] sm:text-sm">
<SelectValue placeholder="Sheet1" />
</SelectTrigger>
<SelectContent>
{sheetNames.map((name) => (
<SelectItem key={name} value={name} className="text-xs sm:text-sm">
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="text-xs text-muted-foreground">
{displayData.length}
</span>
</div>
<EditableSpreadsheet
columns={excelColumns}
data={displayData}
onColumnsChange={setExcelColumns}
onDataChange={(newData) => {
setDisplayData(newData);
setAllData(newData);
}}
maxHeight="250px"
/>
</>
)}
</div>
)}
{/* 2단계: 컬럼 매핑 */}
{currentStep === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<Button
type="button"
variant="default"
size="sm"
onClick={performAutoMapping}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Zap className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
<div> </div>
<div></div>
<div> </div>
</div>
<div className="max-h-[300px] space-y-2 overflow-y-auto">
{columnMappings.map((mapping, index) => (
<div
key={index}
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2"
>
<div className="rounded-md border border-border bg-muted px-3 py-2 text-xs font-medium sm:text-sm">
{mapping.excelColumn}
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Select
value={mapping.targetColumn || "none"}
onValueChange={(value) =>
handleMappingChange(
mapping.excelColumn,
value === "none" ? null : value
)
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="매핑 안함">
{mapping.targetColumn || "매핑 안함"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs sm:text-sm">
</SelectItem>
{activeColumns.map((ac) => (
<SelectItem
key={`${ac.levelLabel}-${ac.dbColumn}`}
value={ac.excelHeader}
className="text-xs sm:text-sm"
>
{ac.required && (
<span className="mr-1 text-destructive">*</span>
)}
[{ac.levelLabel}] {ac.excelHeader} ({ac.dbColumn})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
{/* 미매핑 필수 컬럼 경고 */}
{(() => {
const mappedTargets = new Set(
columnMappings.filter((m) => m.targetColumn).map((m) => m.targetColumn)
);
const missing = activeColumns.filter(
(ac) => ac.required && !mappedTargets.has(ac.excelHeader)
);
if (missing.length === 0) return null;
return (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
<div className="text-[10px] text-destructive sm:text-xs">
<p className="font-medium"> :</p>
<p className="mt-1">
{missing.map((m) => `[${m.levelLabel}] ${m.excelHeader}`).join(", ")}
</p>
</div>
</div>
</div>
);
})()}
{/* 모드 정보 */}
{selectedMode && (
<div className="rounded-md border border-muted bg-muted/30 p-3">
<div className="flex items-start gap-2">
<Zap className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="text-[10px] text-muted-foreground sm:text-xs">
<p className="font-medium">: {selectedMode.label}</p>
<p className="mt-1">
:{" "}
{selectedMode.activeLevels
.map((i) => config.levels[i]?.label)
.filter(Boolean)
.join(" → ")}
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* 3단계: 확인 */}
{currentStep === 3 && (
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/50 p-4">
<h3 className="text-sm font-medium sm:text-base"> </h3>
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
<p><span className="font-medium">:</span> {file?.name}</p>
<p><span className="font-medium">:</span> {selectedSheet}</p>
<p><span className="font-medium"> :</span> {allData.length}</p>
<p><span className="font-medium">:</span> {selectedMode?.label}</p>
<p>
<span className="font-medium"> :</span>{" "}
{selectedMode?.activeLevels
.map((i) => {
const level = config.levels[i];
return level
? `${level.label}(${level.tableName})`
: "";
})
.filter(Boolean)
.join(" → ")}
</p>
</div>
</div>
<div className="rounded-md border border-border bg-muted/50 p-4">
<h3 className="text-sm font-medium sm:text-base"> </h3>
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
{columnMappings
.filter((m) => m.targetColumn)
.map((mapping, idx) => {
const ac = activeColumns.find(
(c) => c.excelHeader === mapping.targetColumn
);
return (
<p key={idx}>
<span className="font-medium">{mapping.excelColumn}</span>{" "}
[{ac?.levelLabel}] {mapping.targetColumn}
</p>
);
})}
</div>
</div>
<div className="rounded-md border border-warning bg-warning/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-warning" />
<div className="text-[10px] text-warning sm:text-xs">
<p className="font-medium"></p>
<p className="mt-1">
.
.
</p>
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
disabled={isUploading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{currentStep === 1 ? "취소" : "이전"}
</Button>
{currentStep < 3 ? (
<Button
onClick={handleNext}
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
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
onClick={handleUpload}
disabled={isUploading || columnMappings.filter((m) => m.targetColumn).length === 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"업로드"
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</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" />
<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) =>
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>
<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
onClick={applyCategoryReplacements}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};