- Enhanced the `getProcessEquipments` function to support matching both legacy equipment codes and new IDs, improving data retrieval accuracy. - Updated the `availableEquipments` logic in the `ProcessMasterTab` component to handle both equipment codes and IDs, ensuring a seamless user experience when adding equipment. - Improved error handling for equipment selection, providing user feedback when a selected equipment cannot be found. - Refactored the display of equipment names to ensure accurate representation, even when equipment codes are not available.
605 lines
23 KiB
TypeScript
605 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef, useCallback } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Download,
|
|
Upload,
|
|
FileSpreadsheet,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
ArrowRight,
|
|
Info,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@/lib/utils";
|
|
import type {
|
|
SmartExcelUploadConfig,
|
|
ParseResult,
|
|
ParsedSheetData,
|
|
ValidationError,
|
|
ItemProcessMapping,
|
|
} from "./types";
|
|
// ValidationError is used in customValidator prop signature
|
|
export type { ValidationError };
|
|
import { generateTemplate } from "./templateGenerator";
|
|
import type { GenerateTemplateOptions } from "./templateGenerator";
|
|
import { parseTemplate } from "./templateParser";
|
|
import type { ParseOptions } from "./templateParser";
|
|
|
|
export interface SmartExcelUploadModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
config: SmartExcelUploadConfig;
|
|
/** 참조 데이터 (DB에서 조회한 검사기준 등) */
|
|
referenceData?: Record<string, any>[];
|
|
/** 시트별/컬럼별 드롭다운 옵션 */
|
|
dropdownOptions?: Record<string, string[]>;
|
|
/** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */
|
|
itemProcessMappings?: ItemProcessMapping[];
|
|
/** 라벨→코드 변환 매핑 */
|
|
labelToCodeMap?: Record<string, Record<string, string>>;
|
|
/** 추가 메타 정보 (item_code 등) */
|
|
extraMeta?: Record<string, string>;
|
|
/** 업로드 완료 콜백 */
|
|
onUpload: (data: ParsedSheetData[]) => Promise<void>;
|
|
/** 파싱 후 추가 검증 (text 타입 카테고리 검증 등 커스텀 로직) — ValidationError[] 반환, 빈 배열이면 통과 */
|
|
customValidator?: (data: ParsedSheetData[]) => ValidationError[];
|
|
/** 품목명 등 표시용 제목 */
|
|
subtitle?: string;
|
|
/** 데이터 로딩 중 여부 (외부에서 품목 등 로딩 시) */
|
|
dataLoading?: boolean;
|
|
/** 로딩 진행률 */
|
|
loadProgress?: { loaded: number; total: number };
|
|
}
|
|
|
|
type Step = "download" | "upload" | "validate" | "preview";
|
|
|
|
export function SmartExcelUploadModal({
|
|
open,
|
|
onOpenChange,
|
|
config,
|
|
referenceData = [],
|
|
dropdownOptions = {},
|
|
itemProcessMappings = [],
|
|
labelToCodeMap = {},
|
|
extraMeta = {},
|
|
onUpload,
|
|
customValidator,
|
|
subtitle,
|
|
dataLoading = false,
|
|
loadProgress,
|
|
}: SmartExcelUploadModalProps) {
|
|
const [step, setStep] = useState<Step>("download");
|
|
const [downloading, setDownloading] = useState(false);
|
|
const [templateDownloaded, setTemplateDownloaded] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [parseResult, setParseResult] = useState<ParseResult | null>(null);
|
|
const [previewTab, setPreviewTab] = useState(0);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const reset = useCallback(() => {
|
|
setStep("download");
|
|
setTemplateDownloaded(false);
|
|
setParseResult(null);
|
|
setPreviewTab(0);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
}, []);
|
|
|
|
const handleClose = (open: boolean) => {
|
|
if (!open) reset();
|
|
onOpenChange(open);
|
|
};
|
|
|
|
// ═══════════════════ 템플릿 다운로드 ═══════════════════
|
|
const handleDownload = async () => {
|
|
setDownloading(true);
|
|
try {
|
|
const options: GenerateTemplateOptions = {
|
|
config,
|
|
referenceData,
|
|
dropdownOptions,
|
|
itemProcessMappings: itemProcessMappings.length > 0 ? itemProcessMappings : undefined,
|
|
extraMeta,
|
|
};
|
|
const buffer = await generateTemplate(options);
|
|
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.templateName}_템플릿.xlsx`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
setTemplateDownloaded(true);
|
|
toast.success("템플릿 다운로드 완료");
|
|
} catch (err) {
|
|
console.error("템플릿 생성 실패:", err);
|
|
toast.error("템플릿 생성에 실패했습니다");
|
|
} finally {
|
|
setDownloading(false);
|
|
}
|
|
};
|
|
|
|
// ═══════════════════ 파일 업로드 + 파싱 ═══════════════════
|
|
const handleFileChange = async (
|
|
e: React.ChangeEvent<HTMLInputElement>
|
|
) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
// 같은 파일 재선택 허용
|
|
e.target.value = "";
|
|
|
|
if (!file.name.endsWith(".xlsx") && !file.name.endsWith(".xls")) {
|
|
toast.error("엑셀 파일(.xlsx)만 업로드 가능합니다");
|
|
return;
|
|
}
|
|
|
|
setUploading(true);
|
|
setStep("validate");
|
|
setPreviewTab(0);
|
|
|
|
try {
|
|
const parseOptions: ParseOptions = {
|
|
config,
|
|
file,
|
|
currentReferenceData: referenceData,
|
|
currentDropdownOptions: dropdownOptions,
|
|
currentItemProcessMappings: itemProcessMappings.length > 0 ? itemProcessMappings : undefined,
|
|
labelToCodeMap,
|
|
};
|
|
const result = await parseTemplate(parseOptions);
|
|
|
|
// 커스텀 검증 (text 타입 카테고리 등 parseTemplate가 못 잡는 항목)
|
|
if (customValidator && result.data.length > 0) {
|
|
const customErrors = customValidator(result.data);
|
|
if (customErrors.length > 0) {
|
|
result.errors = [...result.errors, ...customErrors];
|
|
result.success = false;
|
|
}
|
|
}
|
|
|
|
setParseResult(result);
|
|
|
|
if (result.success) {
|
|
setStep("preview");
|
|
toast.success("검증 통과! 미리보기를 확인해주세요");
|
|
} else if (result.warnings.length > 0 && result.errors.length === 0) {
|
|
// 경고만 있는 경우
|
|
setStep("validate");
|
|
} else {
|
|
setStep("validate");
|
|
toast.error(`검증 실패: ${result.errors.length}건의 오류`);
|
|
}
|
|
} catch (err) {
|
|
console.error("파싱 실패:", err);
|
|
toast.error("파일 파싱에 실패했습니다");
|
|
setStep("upload");
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
// ═══════════════════ 저장 ═══════════════════
|
|
const handleSave = async () => {
|
|
if (!parseResult?.data?.length) return;
|
|
setSaving(true);
|
|
try {
|
|
await onUpload(parseResult.data);
|
|
toast.success("업로드 완료");
|
|
handleClose(false);
|
|
} catch (err) {
|
|
console.error("저장 실패:", err);
|
|
toast.error("저장에 실패했습니다");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// ═══════════════════ 렌더링 ═══════════════════
|
|
const totalRows =
|
|
parseResult?.data?.reduce((sum, d) => sum + d.rows.length, 0) || 0;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="sm:max-w-[800px] max-h-[85vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<FileSpreadsheet className="h-5 w-5" />
|
|
{config.templateName} 엑셀 업로드
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{subtitle || "템플릿을 다운로드하여 데이터를 작성한 후 업로드해주세요"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* 스텝 인디케이터 */}
|
|
<div className="flex items-center gap-2 px-1 py-2">
|
|
{(
|
|
[
|
|
{ key: "download", label: "템플릿 다운로드", icon: Download },
|
|
{ key: "upload", label: "파일 업로드", icon: Upload },
|
|
{ key: "validate", label: "검증", icon: AlertCircle },
|
|
{ key: "preview", label: "미리보기", icon: CheckCircle2 },
|
|
] as const
|
|
).map(({ key, label, icon: Icon }, i) => {
|
|
const stepOrder = ["download", "upload", "validate", "preview"];
|
|
const currentIdx = stepOrder.indexOf(step);
|
|
const thisIdx = stepOrder.indexOf(key);
|
|
const isActive = key === step;
|
|
const isDone = thisIdx < currentIdx;
|
|
return (
|
|
<React.Fragment key={key}>
|
|
{i > 0 && (
|
|
<ArrowRight
|
|
className={cn(
|
|
"h-3 w-3 shrink-0",
|
|
isDone
|
|
? "text-primary"
|
|
: "text-muted-foreground/40"
|
|
)}
|
|
/>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors",
|
|
isActive
|
|
? "bg-primary text-primary-foreground"
|
|
: isDone
|
|
? "bg-primary/10 text-primary"
|
|
: "bg-muted text-muted-foreground"
|
|
)}
|
|
>
|
|
<Icon className="h-3 w-3" />
|
|
{label}
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 컨텐츠 영역 */}
|
|
<div className="flex-1 min-h-0 overflow-auto space-y-4">
|
|
{/* ── 다운로드 단계 ── */}
|
|
{(step === "download" || step === "upload") && (
|
|
<div className="space-y-4">
|
|
{/* 데이터 로딩 진행률 */}
|
|
{dataLoading && loadProgress && loadProgress.total > 0 && (
|
|
<div className="border rounded-lg p-4 space-y-2 bg-blue-50/50 border-blue-200">
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
|
<span className="text-sm font-medium text-blue-700">
|
|
데이터 로딩 중...
|
|
</span>
|
|
<span className="text-xs text-blue-600 ml-auto">
|
|
{loadProgress.loaded.toLocaleString()} / {loadProgress.total.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-blue-100 rounded-full h-1.5">
|
|
<div
|
|
className="bg-blue-600 h-1.5 rounded-full transition-all duration-300"
|
|
style={{ width: `${Math.round((loadProgress.loaded / loadProgress.total) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 템플릿 다운로드 */}
|
|
<div
|
|
className={cn(
|
|
"border rounded-lg p-4 space-y-3",
|
|
templateDownloaded
|
|
? "border-primary/30 bg-primary/5"
|
|
: "border-dashed",
|
|
dataLoading && "opacity-50 pointer-events-none"
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Download
|
|
className={cn(
|
|
"h-4 w-4",
|
|
templateDownloaded
|
|
? "text-primary"
|
|
: "text-muted-foreground"
|
|
)}
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
1. 템플릿 다운로드
|
|
</span>
|
|
{templateDownloaded && (
|
|
<Badge
|
|
variant="default"
|
|
className="text-[10px] h-5"
|
|
>
|
|
완료
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant={templateDownloaded ? "outline" : "default"}
|
|
onClick={handleDownload}
|
|
disabled={downloading || dataLoading}
|
|
className="h-8"
|
|
>
|
|
{downloading ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
|
) : (
|
|
<Download className="h-3.5 w-3.5 mr-1" />
|
|
)}
|
|
{templateDownloaded ? "다시 다운로드" : "템플릿 다운로드"}
|
|
</Button>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground space-y-1">
|
|
<p>
|
|
시트 구성:{" "}
|
|
{config.sheets.map((s) => s.name).join(", ")}
|
|
</p>
|
|
{config.referenceSheet && (
|
|
<p>참조 데이터: {referenceData.length}건 포함</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 파일 업로드 */}
|
|
<div
|
|
className={cn(
|
|
"border rounded-lg p-4 space-y-3",
|
|
dataLoading && "opacity-50 pointer-events-none"
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Upload className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">
|
|
2. 파일 업로드
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".xlsx,.xls"
|
|
onChange={handleFileChange}
|
|
className="text-sm file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer"
|
|
disabled={dataLoading || uploading}
|
|
/>
|
|
{uploading && (
|
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
|
)}
|
|
</div>
|
|
{dataLoading && (
|
|
<p className="text-xs text-amber-600 flex items-center gap-1">
|
|
<Info className="h-3 w-3" />
|
|
데이터 로딩 중입니다. 잠시 기다려주세요
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 검증 결과 (에러) ── */}
|
|
{step === "validate" && parseResult && (
|
|
<div className="space-y-4">
|
|
{/* 경고 메시지 */}
|
|
{parseResult.warnings.map((w, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800"
|
|
>
|
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
|
<span className="text-sm">{w}</span>
|
|
</div>
|
|
))}
|
|
|
|
{/* 에러 목록 */}
|
|
{parseResult.errors.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<XCircle className="h-4 w-4 text-destructive" />
|
|
<span className="text-sm font-medium text-destructive">
|
|
검증 오류 {parseResult.errors.length}건
|
|
</span>
|
|
</div>
|
|
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50">
|
|
<TableHead className="text-[10px] font-bold w-[100px]">
|
|
시트
|
|
</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[60px]">
|
|
행
|
|
</TableHead>
|
|
<TableHead className="text-[10px] font-bold w-[100px]">
|
|
컬럼
|
|
</TableHead>
|
|
<TableHead className="text-[10px] font-bold">
|
|
오류 내용
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{parseResult.errors.map((err, i) => (
|
|
<TableRow key={i}>
|
|
<TableCell className="text-xs py-1.5">
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px]"
|
|
>
|
|
{err.sheet}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-xs py-1.5 font-mono">
|
|
{err.row}행
|
|
</TableCell>
|
|
<TableCell className="text-xs py-1.5">
|
|
{err.column}
|
|
</TableCell>
|
|
<TableCell className="text-xs py-1.5 text-destructive">
|
|
{err.message}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setStep("upload");
|
|
setParseResult(null);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
}}
|
|
>
|
|
다시 업로드
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 미리보기 ── */}
|
|
{step === "preview" && parseResult?.data && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
<span className="text-sm font-medium text-green-700">
|
|
검증 통과
|
|
</span>
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
총 {totalRows}건
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* 시트 탭 */}
|
|
{parseResult.data.length > 1 && (
|
|
<div className="flex gap-1.5">
|
|
{parseResult.data.map((sheet, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={() => setPreviewTab(i)}
|
|
className={cn(
|
|
"px-3 py-1.5 rounded-full text-xs font-medium transition-colors border",
|
|
previewTab === i
|
|
? "bg-primary text-primary-foreground border-primary"
|
|
: "bg-muted/50 text-muted-foreground border-border hover:bg-muted"
|
|
)}
|
|
>
|
|
{sheet.sheetName}
|
|
<span className="ml-1 opacity-70">
|
|
({sheet.rows.length})
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 데이터 테이블 */}
|
|
{parseResult.data[previewTab] && (
|
|
<div className="border rounded-lg overflow-hidden max-h-[350px] overflow-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50">
|
|
<TableHead className="text-[10px] font-bold w-[40px]">
|
|
#
|
|
</TableHead>
|
|
{(() => {
|
|
const sheetData = parseResult.data[previewTab];
|
|
const sheetConfig = config.sheets.find(
|
|
(s) => s.name === sheetData.sheetName
|
|
);
|
|
return (sheetConfig?.columns || [])
|
|
.filter((c) => !c.readOnly && !c.autoFill && !c.customFormula)
|
|
.map((col) => (
|
|
<TableHead
|
|
key={col.key}
|
|
className="text-[10px] font-bold"
|
|
>
|
|
{col.label}
|
|
</TableHead>
|
|
));
|
|
})()}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{parseResult.data[previewTab].rows.map((row, i) => {
|
|
const sheetData = parseResult.data[previewTab];
|
|
const sheetConfig = config.sheets.find(
|
|
(s) => s.name === sheetData.sheetName
|
|
);
|
|
return (
|
|
<TableRow key={i}>
|
|
<TableCell className="text-[10px] py-1.5 text-muted-foreground">
|
|
{i + 1}
|
|
</TableCell>
|
|
{(sheetConfig?.columns || [])
|
|
.filter((c) => !c.readOnly && !c.autoFill && !c.customFormula)
|
|
.map((col) => (
|
|
<TableCell
|
|
key={col.key}
|
|
className="text-xs py-1.5"
|
|
>
|
|
{row[`${col.key}_label`] ||
|
|
row[col.key] ||
|
|
"-"}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="shrink-0">
|
|
<Button variant="outline" onClick={() => handleClose(false)}>
|
|
취소
|
|
</Button>
|
|
{step === "preview" && (
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
|
) : (
|
|
<Upload className="h-4 w-4 mr-1" />
|
|
)}
|
|
{totalRows}건 업로드
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|