feat: Add Smart Excel Upload functionality for item inspection
- Introduced a new SmartExcelUploadModal component to facilitate bulk item inspection uploads via Excel. - Implemented logic for downloading templates, validating uploaded files, and parsing data for inspection criteria. - Enhanced the item inspection page to support dynamic loading of item process mappings and reference data for improved user experience. - Added necessary types and utility functions for template generation and parsing, ensuring robust handling of Excel data. - These changes aim to streamline the item inspection process and improve data management across multiple company implementations.
This commit is contained in:
@@ -0,0 +1,589 @@
|
||||
"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";
|
||||
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>;
|
||||
/** 품목명 등 표시용 제목 */
|
||||
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,
|
||||
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);
|
||||
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>
|
||||
);
|
||||
}
|
||||
16
frontend/components/common/SmartExcelUpload/index.ts
Normal file
16
frontend/components/common/SmartExcelUpload/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { SmartExcelUploadModal } from "./SmartExcelUploadModal";
|
||||
export type {
|
||||
SmartExcelUploadConfig,
|
||||
SheetConfig,
|
||||
SmartColumn,
|
||||
ReferenceSheetConfig,
|
||||
ConditionalRule,
|
||||
DropdownConfig,
|
||||
ItemProcessMapping,
|
||||
IndirectOptionsConfig,
|
||||
ParsedSheetData,
|
||||
ParseResult,
|
||||
ValidationError,
|
||||
} from "./types";
|
||||
export { generateTemplate, regenerateHash } from "./templateGenerator";
|
||||
export { parseTemplate } from "./templateParser";
|
||||
528
frontend/components/common/SmartExcelUpload/templateGenerator.ts
Normal file
528
frontend/components/common/SmartExcelUpload/templateGenerator.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* SmartExcelUpload — 템플릿 생성기
|
||||
* ExcelJS 기반: 드롭다운, VLOOKUP 수식, INDIRECT 동적 드롭다운,
|
||||
* 참조시트, 조건부서식, _meta 해시시트
|
||||
*/
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
import type {
|
||||
SmartExcelUploadConfig,
|
||||
ItemProcessMapping,
|
||||
} from "./types";
|
||||
|
||||
/** 카테고리 값들로 해시 생성 */
|
||||
function generateHash(data: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash + char) | 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
/** 엑셀 컬럼 문자 (1→A, 2→B, ..., 27→AA) */
|
||||
function colLetter(index: number): string {
|
||||
let result = "";
|
||||
let n = index;
|
||||
while (n > 0) {
|
||||
n--;
|
||||
result = String.fromCharCode(65 + (n % 26)) + result;
|
||||
n = Math.floor(n / 26);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 품목코드를 엑셀 이름 범위에 사용 가능한 이름으로 변환 */
|
||||
function sanitizeNameForExcel(itemCode: string): string {
|
||||
// 엑셀 이름 범위 규칙: 문자/밑줄로 시작, 영숫자/밑줄만 허용
|
||||
return "P_" + itemCode.replace(/[^a-zA-Z0-9]/g, "_");
|
||||
}
|
||||
|
||||
export interface GenerateTemplateOptions {
|
||||
config: SmartExcelUploadConfig;
|
||||
/** 참조시트 데이터 (DB에서 조회한 검사기준 등) */
|
||||
referenceData?: Record<string, any>[];
|
||||
/** 시트별 드롭다운 옵션 (카테고리 값 등) */
|
||||
dropdownOptions?: Record<string, string[]>;
|
||||
/** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */
|
||||
itemProcessMappings?: ItemProcessMapping[];
|
||||
/** 추가 메타 정보 */
|
||||
extraMeta?: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function generateTemplate(
|
||||
options: GenerateTemplateOptions
|
||||
): Promise<ExcelJS.Buffer> {
|
||||
const { config, referenceData, dropdownOptions, itemProcessMappings, extraMeta } = options;
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = "WACE ERP";
|
||||
workbook.created = new Date();
|
||||
|
||||
// 해시 소스: 참조 데이터 + 드롭다운 옵션 + 품목공정매핑
|
||||
const hashSource = JSON.stringify({
|
||||
referenceData,
|
||||
dropdownOptions,
|
||||
itemProcessMappings: itemProcessMappings?.map(m => ({
|
||||
itemCode: m.itemCode,
|
||||
processes: m.processes.map(p => p.name),
|
||||
})),
|
||||
});
|
||||
const versionHash = generateHash(hashSource);
|
||||
|
||||
// ═══════════════════ 1. 참조시트 생성 ═══════════════════
|
||||
let refSheet: ExcelJS.Worksheet | null = null;
|
||||
const refDataRows = referenceData || config.referenceSheet?.data || [];
|
||||
const refCols = config.referenceSheet?.columns || [];
|
||||
|
||||
if (config.referenceSheet && refCols.length > 0) {
|
||||
refSheet = workbook.addWorksheet(config.referenceSheet.name, {
|
||||
state: "veryHidden",
|
||||
});
|
||||
|
||||
// 헤더
|
||||
const headerRow = refSheet.addRow(refCols.map((c) => c.label));
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.font = { bold: true, size: 10 };
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFE2E8F0" } };
|
||||
cell.border = { bottom: { style: "thin", color: { argb: "FF94A3B8" } } };
|
||||
});
|
||||
|
||||
for (const row of refDataRows) {
|
||||
refSheet.addRow(refCols.map((c) => row[c.key] ?? ""));
|
||||
}
|
||||
|
||||
refCols.forEach((_, i) => { refSheet!.getColumn(i + 1).width = 20; });
|
||||
}
|
||||
|
||||
// ═══════════════════ 1-B. 합격기준 옵션 시트 (INDIRECT용) ═══════════════════
|
||||
// 검사기준별 판단기준에 따라 합격기준 드롭다운을 동적으로 변경
|
||||
// 한글은 엑셀 이름 범위에 사용 불가 → 숫자 인덱스 ACC_1, ACC_2... 사용
|
||||
// INDIRECT("ACC_" & MATCH(검사기준셀, 검사기준정보!A열, 0)) 방식
|
||||
// ═══════════════════ 1-B. INDIRECT ACC_ 동적 드롭다운 옵션 시트 ═══════════════════
|
||||
const hasAccPrefix = config.sheets.some(s => s.columns.some(c => c.dropdown?.indirectPrefix === "ACC_"));
|
||||
if (refDataRows.length > 0 && hasAccPrefix && config.indirectOptions) {
|
||||
const accSheet = workbook.addWorksheet("_합격기준옵션", { state: "veryHidden" });
|
||||
const { conditionColumn, optionsByCondition, selectionOptionsColumn } = config.indirectOptions;
|
||||
|
||||
for (let i = 0; i < refDataRows.length; i++) {
|
||||
const row = refDataRows[i];
|
||||
const condValue = row[conditionColumn] || "";
|
||||
const rowNum = i + 1;
|
||||
const safeName = `ACC_${rowNum}`;
|
||||
|
||||
// 조건값에 매칭되는 고정 옵션 확인
|
||||
let options: string[] = optionsByCondition[condValue] || [];
|
||||
|
||||
// 선택옵션 컬럼에서 동적으로 가져오기 (콤마 구분)
|
||||
if (options.length === 0 && selectionOptionsColumn) {
|
||||
const selOpts = row[selectionOptionsColumn] || "";
|
||||
if (selOpts) {
|
||||
options = selOpts.split(",").map((s: string) => s.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.length > 0) {
|
||||
for (let j = 0; j < options.length; j++) {
|
||||
accSheet.getCell(`${colLetter(j + 1)}${rowNum}`).value = options[j];
|
||||
}
|
||||
const endCol = colLetter(options.length);
|
||||
try {
|
||||
workbook.definedNames.add(`'_합격기준옵션'!$A$${rowNum}:$${endCol}$${rowNum}`, safeName);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
// 매칭 안 되면 이름 범위 미등록 → INDIRECT 실패 → 자유 입력
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════ 2. 품목공정 매핑 시트 (INDIRECT용) ═══════════════════
|
||||
const hasItemProcess = itemProcessMappings && itemProcessMappings.length > 0;
|
||||
if (hasItemProcess) {
|
||||
const procSheet = workbook.addWorksheet("_품목공정", { state: "veryHidden" });
|
||||
procSheet.getColumn(1).width = 20;
|
||||
|
||||
// 품목별로 열에 공정 목록 배치, 이름 범위 등록
|
||||
// 방식: 각 품목코드를 열 방향으로 배치 (A열: 품목1 공정들, B열: 품목2 공정들...)
|
||||
// → 이름 범위 수가 너무 많아질 수 있으므로, 행 방향으로 배치
|
||||
// 방식 변경: 품목코드를 A열, 공정을 B열~에 넣고, 이름 범위로 등록
|
||||
// → ExcelJS는 definedNames 지원
|
||||
|
||||
for (let i = 0; i < itemProcessMappings!.length; i++) {
|
||||
const mapping = itemProcessMappings![i];
|
||||
const safeName = sanitizeNameForExcel(mapping.itemCode);
|
||||
const rowNum = i + 1;
|
||||
|
||||
// A열: 품목코드 (참조용)
|
||||
procSheet.getCell(`A${rowNum}`).value = mapping.itemCode;
|
||||
|
||||
// B열~: 공정명들
|
||||
const procs = mapping.processes;
|
||||
if (procs.length > 0) {
|
||||
for (let j = 0; j < procs.length; j++) {
|
||||
procSheet.getCell(`${colLetter(j + 2)}${rowNum}`).value = procs[j].name;
|
||||
}
|
||||
// 이름 범위 등록: add(rangeString, name)
|
||||
const startCol = colLetter(2); // B
|
||||
const endCol = colLetter(procs.length + 1);
|
||||
workbook.definedNames.add(`'_품목공정'!$${startCol}$${rowNum}:$${endCol}$${rowNum}`, safeName);
|
||||
}
|
||||
// 공정 없는 품목: 이름 범위 미등록 → INDIRECT 실패 → 드롭다운 안 뜸 (자유 입력)
|
||||
}
|
||||
|
||||
// 품목코드 목록도 별도 시트에 (드롭다운용)
|
||||
const itemListSheet = workbook.addWorksheet("_품목목록", { state: "veryHidden" });
|
||||
itemProcessMappings!.forEach((m, i) => {
|
||||
itemListSheet.getCell(`A${i + 1}`).value = m.itemCode;
|
||||
itemListSheet.getCell(`B${i + 1}`).value = m.itemName;
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════ 3. 검사시트별 생성 ═══════════════════
|
||||
for (const sheetConfig of config.sheets) {
|
||||
const ws = workbook.addWorksheet(sheetConfig.name);
|
||||
|
||||
// 컬럼 너비
|
||||
sheetConfig.columns.forEach((col, i) => {
|
||||
ws.getColumn(i + 1).width = col.width || 18;
|
||||
});
|
||||
|
||||
// 헤더 행
|
||||
const headerRow = ws.addRow(sheetConfig.columns.map((c) => c.label));
|
||||
headerRow.height = 28;
|
||||
headerRow.eachCell((cell, colNumber) => {
|
||||
const col = sheetConfig.columns[colNumber - 1];
|
||||
cell.font = { bold: true, size: 10, color: { argb: "FF1E293B" } };
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF1F5F9" } };
|
||||
cell.border = { bottom: { style: "medium", color: { argb: "FF94A3B8" } } };
|
||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||
|
||||
if (col?.required) {
|
||||
cell.font = { bold: true, size: 10, color: { argb: "FFDC2626" } };
|
||||
}
|
||||
if (col?.readOnly || col?.autoFill) {
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFE2E8F0" } };
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════ 컬럼 범위 기반 data validation + 수식 (행 제한 없음) ═══════
|
||||
// 행 2부터 10000까지 범위 설정 (셀 루프 대신 범위 단위)
|
||||
// 수식 행 수: 성능과 사용성 균형 (드롭다운 validation은 범위라 무제한)
|
||||
const FORMULA_END = 2000;
|
||||
const VALIDATION_END = 65000;
|
||||
|
||||
for (let colIdx = 0; colIdx < sheetConfig.columns.length; colIdx++) {
|
||||
const col = sheetConfig.columns[colIdx];
|
||||
const colL = colLetter(colIdx + 1);
|
||||
const rangeStr = `${colL}2:${colL}${VALIDATION_END}`;
|
||||
|
||||
// ── 수식 컬럼: 2행에만 수식 세팅 (사용자가 아래로 복사하거나 테이블 확장) ──
|
||||
if (col.customFormula) {
|
||||
let formula = col.customFormula;
|
||||
formula = formula.replace(/\{col:(\w+)\}/g, (_, key) => {
|
||||
const idx = sheetConfig.columns.findIndex(c => c.key === key);
|
||||
return idx >= 0 ? `${colLetter(idx + 1)}2` : `"?"`;
|
||||
});
|
||||
formula = formula.replace(/\{itemCount\}/g, String(itemProcessMappings?.length || 9999));
|
||||
// 2행~FORMULA_END 행에 수식 삽입 (잠금 + 회색 배경)
|
||||
for (let r = 2; r <= FORMULA_END; r++) {
|
||||
// 상대참조만 치환 (A2→A{r}), 절대참조($A$2)는 유지
|
||||
const rowFormula = formula.replace(/(?<!\$)([A-Z]+)(?<!\$)2(?![0-9])/g, (match, c) => `${c}${r}`);
|
||||
const cell = ws.getCell(`${colL}${r}`);
|
||||
cell.value = { formula: rowFormula } as any;
|
||||
cell.protection = { locked: true };
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF8FAFC" } };
|
||||
cell.font = { size: 10, color: { argb: "FF64748B" } };
|
||||
cell.border = { top: { style: "hair", color: { argb: "FFE2E8F0" } }, bottom: { style: "hair", color: { argb: "FFE2E8F0" } }, left: { style: "hair", color: { argb: "FFE2E8F0" } }, right: { style: "hair", color: { argb: "FFE2E8F0" } } };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (col.autoFill && refSheet && refCols.length > 0) {
|
||||
const lookupColIdx = sheetConfig.columns.findIndex(c => c.key === col.autoFill!.lookupColumn);
|
||||
const refColIdx = refCols.findIndex(c => c.key === col.autoFill!.referenceColumn) + 1;
|
||||
if (lookupColIdx >= 0 && refColIdx > 0) {
|
||||
const refRange = `'${config.referenceSheet!.name}'!$A$2:$${colLetter(refCols.length)}$${refDataRows.length + 1}`;
|
||||
for (let r = 2; r <= FORMULA_END; r++) {
|
||||
const lookupCell = `${colLetter(lookupColIdx + 1)}${r}`;
|
||||
const cell = ws.getCell(`${colL}${r}`);
|
||||
cell.value = { formula: `IFERROR(VLOOKUP(${lookupCell},${refRange},${refColIdx},FALSE),"")` } as any;
|
||||
cell.protection = { locked: true };
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF8FAFC" } };
|
||||
cell.font = { size: 10, color: { argb: "FF64748B" } };
|
||||
cell.border = { top: { style: "hair", color: { argb: "FFE2E8F0" } }, bottom: { style: "hair", color: { argb: "FFE2E8F0" } }, left: { style: "hair", color: { argb: "FFE2E8F0" } }, right: { style: "hair", color: { argb: "FFE2E8F0" } } };
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── data validation: 범위 단위로 한 번에 설정 ──
|
||||
// enableWhen (참조시트 직접 조회)
|
||||
if (col.enableWhen && refSheet && refCols.length > 0 && refDataRows.length > 0) {
|
||||
const condRefColIdx = refCols.findIndex(c => c.key === col.enableWhen!.column);
|
||||
const lookupKeyColIdx = sheetConfig.columns.findIndex(c => c.autoFill?.referenceColumn === col.enableWhen!.column);
|
||||
const lookupSourceColIdx = lookupKeyColIdx >= 0
|
||||
? sheetConfig.columns.findIndex(c => c.key === sheetConfig.columns[lookupKeyColIdx].autoFill?.lookupColumn)
|
||||
: -1;
|
||||
|
||||
if (condRefColIdx >= 0 && lookupSourceColIdx >= 0) {
|
||||
const lookupColL = colLetter(lookupSourceColIdx + 1);
|
||||
const refRange = `'${config.referenceSheet!.name}'!$A$2:$A$${refDataRows.length + 1}`;
|
||||
const refValueRange = `'${config.referenceSheet!.name}'!$${colLetter(condRefColIdx + 1)}$2:$${colLetter(condRefColIdx + 1)}$${refDataRows.length + 1}`;
|
||||
const keyword = col.enableWhen.equals.replace(/[()]/g, "").slice(0, 2);
|
||||
const condColLabel = sheetConfig.columns.find(c => c.key === col.enableWhen!.column)?.label || col.enableWhen!.column;
|
||||
|
||||
// 상대 참조 (2행 기준, 범위 적용 시 자동 조정)
|
||||
(ws as any).dataValidations.add(rangeStr, {
|
||||
type: "custom",
|
||||
allowBlank: true,
|
||||
formulae: [`ISNUMBER(SEARCH("${keyword}",IFERROR(INDEX(${refValueRange},MATCH(${lookupColL}2,${refRange},0)),"")))`],
|
||||
showErrorMessage: true,
|
||||
errorTitle: "입력 불가",
|
||||
error: `${condColLabel}이(가) "${col.enableWhen.equals}"일 때만 입력 가능합니다`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// INDIRECT 동적 드롭다운
|
||||
if (col.dropdown?.source === "indirect") {
|
||||
const prefix = col.dropdown.indirectPrefix || "P_";
|
||||
const keyColName = col.dropdown.indirectKeyColumn;
|
||||
if (!keyColName) continue;
|
||||
const keyColIdx = sheetConfig.columns.findIndex(c => c.key === keyColName);
|
||||
|
||||
if (keyColIdx >= 0) {
|
||||
const keyColL = colLetter(keyColIdx + 1);
|
||||
if (prefix === "ACC_" && refDataRows.length > 0) {
|
||||
const refNameRange = `'${config.referenceSheet!.name}'!$A$2:$A$${refDataRows.length + 1}`;
|
||||
(ws as any).dataValidations.add(rangeStr, {
|
||||
type: "list",
|
||||
allowBlank: true,
|
||||
formulae: [`INDIRECT("ACC_"&MATCH(${keyColL}2,${refNameRange},0))`],
|
||||
showErrorMessage: false,
|
||||
});
|
||||
} else {
|
||||
// sanitizeNameForExcel과 동일한 치환: 비영숫자 → _
|
||||
const sanitizeFormula = `SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(${keyColL}2,"-","_"),".","_")," ","_"),"/","_"),"(","_"),")","_"),"#","_")`;
|
||||
(ws as any).dataValidations.add(rangeStr, {
|
||||
type: "list",
|
||||
allowBlank: true,
|
||||
formulae: [`INDIRECT("${prefix}"&${sanitizeFormula})`],
|
||||
showErrorMessage: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 일반 드롭다운
|
||||
if (col.type === "dropdown") {
|
||||
const optionKey = `${sheetConfig.name}:${col.key}`;
|
||||
const globalKey = col.key;
|
||||
const configValues = col.dropdown?.values;
|
||||
const values =
|
||||
(configValues && configValues.length > 0 ? configValues : null) ||
|
||||
dropdownOptions?.[optionKey] ||
|
||||
dropdownOptions?.[globalKey] ||
|
||||
[];
|
||||
|
||||
if (values.length > 0) {
|
||||
// 품목목록 시트 참조 (품목명 등 대량 드롭다운)
|
||||
const isItemNameDropdown = hasItemProcess && values.length === itemProcessMappings!.length
|
||||
&& values[0] === itemProcessMappings![0]?.itemName;
|
||||
if (isItemNameDropdown) {
|
||||
(ws as any).dataValidations.add(rangeStr, {
|
||||
type: "list",
|
||||
allowBlank: !col.required,
|
||||
formulae: [`'_품목목록'!$B$1:$B$${itemProcessMappings!.length}`],
|
||||
showErrorMessage: true,
|
||||
errorTitle: "입력 오류",
|
||||
error: "품목 목록에서 선택해주세요",
|
||||
});
|
||||
} else {
|
||||
const joined = `"${values.join(",")}"`;
|
||||
if (joined.length <= 255) {
|
||||
(ws as any).dataValidations.add(rangeStr, {
|
||||
type: "list",
|
||||
allowBlank: !col.required,
|
||||
formulae: [joined],
|
||||
showErrorMessage: true,
|
||||
errorTitle: "입력 오류",
|
||||
error: `다음 중 선택: ${values.slice(0, 5).join(", ")}${values.length > 5 ? " ..." : ""}`,
|
||||
});
|
||||
} else {
|
||||
const listSheetName = `_list_${sheetConfig.name}_${col.key}`.slice(0, 31);
|
||||
let listSheet = workbook.getWorksheet(listSheetName);
|
||||
if (!listSheet) {
|
||||
listSheet = workbook.addWorksheet(listSheetName, { state: "veryHidden" });
|
||||
values.forEach((v, i) => { listSheet!.getCell(`A${i + 1}`).value = v; });
|
||||
}
|
||||
(ws as any).dataValidations.add(rangeStr, {
|
||||
type: "list",
|
||||
allowBlank: !col.required,
|
||||
formulae: [`'${listSheetName}'!$A$1:$A$${values.length}`],
|
||||
showErrorMessage: true,
|
||||
errorTitle: "입력 오류",
|
||||
error: "목록에서 선택해주세요",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 숫자 포맷
|
||||
if (col.type === "number") {
|
||||
ws.getColumn(colIdx + 1).numFmt = "#,##0.##";
|
||||
}
|
||||
}
|
||||
|
||||
// 시트 보호 — 수식/자동입력 컬럼 잠금, 편집 컬럼 해제
|
||||
const hasLockedCols = sheetConfig.columns.some(c => c.autoFill || c.readOnly || c.customFormula);
|
||||
if (hasLockedCols) {
|
||||
// Excel 기본: 모든 셀 locked=true → 편집 가능 컬럼만 unlocked 처리
|
||||
const editableColIndices: number[] = [];
|
||||
sheetConfig.columns.forEach((c, ci) => {
|
||||
if (!c.autoFill && !c.readOnly && !c.customFormula) {
|
||||
editableColIndices.push(ci);
|
||||
}
|
||||
});
|
||||
// 편집 가능 컬럼만 unlock (FORMULA_END 행까지)
|
||||
for (const ci of editableColIndices) {
|
||||
const cl = colLetter(ci + 1);
|
||||
for (let r = 2; r <= FORMULA_END; r++) {
|
||||
const editCell = ws.getCell(`${cl}${r}`);
|
||||
editCell.protection = { locked: false };
|
||||
editCell.border = { top: { style: "hair", color: { argb: "FFE2E8F0" } }, bottom: { style: "hair", color: { argb: "FFE2E8F0" } }, left: { style: "hair", color: { argb: "FFE2E8F0" } }, right: { style: "hair", color: { argb: "FFE2E8F0" } } };
|
||||
}
|
||||
}
|
||||
ws.protect("", {
|
||||
selectLockedCells: true,
|
||||
selectUnlockedCells: true,
|
||||
formatCells: true,
|
||||
sort: true,
|
||||
autoFilter: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 고정틀
|
||||
ws.views = [{ state: "frozen", ySplit: 1, xSplit: 0 }];
|
||||
}
|
||||
|
||||
// ═══════════════════ 4. 안내 시트 ═══════════════════
|
||||
const guideSheet = workbook.addWorksheet("안내");
|
||||
guideSheet.getColumn(1).width = 20;
|
||||
guideSheet.getColumn(2).width = 50;
|
||||
|
||||
// Config 기반으로 안내시트 자동 생성
|
||||
const firstSheetCols = config.sheets[0]?.columns || [];
|
||||
const lockedCols = firstSheetCols.filter(c => c.autoFill || c.readOnly || c.customFormula).map(c => c.label);
|
||||
const editableCols = firstSheetCols.filter(c => !c.autoFill && !c.readOnly && !c.customFormula);
|
||||
|
||||
const guideData: [string, string][] = [
|
||||
["템플릿 정보", ""],
|
||||
["파일명", config.templateName],
|
||||
["생성일시", new Date().toLocaleString("ko-KR")],
|
||||
...(hasItemProcess ? [["품목 수", `${itemProcessMappings!.length}건`] as [string, string]] : []),
|
||||
["시트 구성", config.sheets.map(s => s.name).join(", ")],
|
||||
["", ""],
|
||||
["컬럼 설명", ""],
|
||||
// 컬럼별 자동 설명 생성
|
||||
...firstSheetCols.map((col): [string, string] => {
|
||||
const parts: string[] = [];
|
||||
if (col.required) parts.push("필수");
|
||||
if (col.autoFill) parts.push(`${firstSheetCols.find(c => c.key === col.autoFill!.lookupColumn)?.label || ""} 선택 시 자동 입력 (수정 불가)`);
|
||||
else if (col.customFormula) parts.push("자동 입력 (수정 불가)");
|
||||
else if (col.readOnly) parts.push("읽기전용");
|
||||
else if (col.dropdown?.source === "indirect") parts.push("연동 드롭다운 (관련 컬럼 선택 후 목록 표시)");
|
||||
else if (col.type === "dropdown") parts.push("드롭다운에서 선택");
|
||||
else if (col.type === "number") parts.push("숫자 입력");
|
||||
else parts.push("텍스트 입력");
|
||||
if (col.enableWhen) parts.push(`${firstSheetCols.find(c => c.key === col.enableWhen!.column)?.label || col.enableWhen!.column}이(가) "${col.enableWhen.equals}"일 때만 입력 가능`);
|
||||
return [col.label, parts.join(" — ")];
|
||||
}),
|
||||
["", ""],
|
||||
// 조건부 규칙이 있으면 자동 설명
|
||||
...(config.conditionalRules && config.conditionalRules.length > 0 ? [
|
||||
["조건별 입력 규칙", ""] as [string, string],
|
||||
...config.conditionalRules.map((rule): [string, string] => {
|
||||
const reqLabels = rule.require.map(k => firstSheetCols.find(c => c.key === k)?.label || k).join(", ");
|
||||
const ignLabels = rule.ignore.map(k => firstSheetCols.find(c => c.key === k)?.label || k).join(", ");
|
||||
const condLabel = firstSheetCols.find(c => c.key === rule.when.column)?.label || rule.when.column;
|
||||
return [rule.when.equals, `${condLabel}이(가) "${rule.when.equals}"일 때: ${reqLabels} 필수${ignLabels ? `, ${ignLabels} 무시` : ""}`];
|
||||
}),
|
||||
["", ""] as [string, string],
|
||||
] : []),
|
||||
["사용 방법", ""],
|
||||
["1단계", `하단의 시트(${config.sheets.map(s => s.name).join(", ")})로 이동`],
|
||||
...editableCols.slice(0, 3).map((col, i): [string, string] =>
|
||||
[`${i + 2}단계`, `${col.label}${col.type === "dropdown" ? "을(를) 드롭다운에서 선택" : "을(를) 입력"}`]
|
||||
),
|
||||
[`${Math.min(editableCols.length, 3) + 2}단계`, "필요한 시트만 작성 (사용하지 않는 시트는 비워두세요)"],
|
||||
[`${Math.min(editableCols.length, 3) + 3}단계`, "작성 완료 후 시스템에서 파일 업로드"],
|
||||
["", ""],
|
||||
["주의사항", ""],
|
||||
...(lockedCols.length > 0 ? [[
|
||||
"1", `잠금된 셀(${lockedCols.join(", ")})은 자동 입력됩니다. 직접 수정하지 마세요.`
|
||||
] as [string, string]] : []),
|
||||
["2", "사용하지 않는 시트는 비워두면 자동으로 무시됩니다."],
|
||||
["3", "기준 데이터가 변경된 경우 템플릿을 다시 다운로드해주세요."],
|
||||
["4", "업로드 시 입력 데이터를 자동 검증합니다. 오류가 있으면 상세 내용이 표시됩니다."],
|
||||
];
|
||||
|
||||
guideData.forEach(([a, b]) => {
|
||||
const row = guideSheet.addRow([a, b]);
|
||||
if (["템플릿 정보", "컬럼 설명", "사용 방법", "조건별 입력 규칙", "주의사항"].includes(a)) {
|
||||
row.eachCell((cell) => {
|
||||
cell.font = { bold: true, size: 12, color: { argb: "FF1E40AF" } };
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════ 5. _meta 시트 (숨김) ═══════════════════
|
||||
const metaSheet = workbook.addWorksheet("_meta", { state: "veryHidden" });
|
||||
metaSheet.getColumn(1).width = 20;
|
||||
metaSheet.getColumn(2).width = 50;
|
||||
|
||||
const metaEntries: [string, string][] = [
|
||||
["version_hash", versionHash],
|
||||
["created_at", new Date().toISOString()],
|
||||
["template_name", config.templateName],
|
||||
["item_count", hasItemProcess ? String(itemProcessMappings!.length) : "0"],
|
||||
...(Object.entries(extraMeta || {}) as [string, string][]),
|
||||
...(Object.entries(config.extraMeta || {}) as [string, string][]),
|
||||
];
|
||||
metaEntries.forEach(([k, v]) => metaSheet.addRow([k, v]));
|
||||
|
||||
// ═══════════════════ 6. 시트 순서 조정 ═══════════════════
|
||||
const guideIdx = workbook.worksheets.findIndex((ws) => ws.name === "안내");
|
||||
if (guideIdx > 0) {
|
||||
(guideSheet as any).orderNo = 0;
|
||||
let order = 1;
|
||||
for (const ws of workbook.worksheets) {
|
||||
if (ws.name !== "안내") {
|
||||
(ws as any).orderNo = order++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** 현재 DB 데이터 기반 해시 재생성 (업로드 검증용) */
|
||||
export function regenerateHash(
|
||||
referenceData: Record<string, any>[],
|
||||
dropdownOptions: Record<string, string[]>,
|
||||
itemProcessMappings?: ItemProcessMapping[]
|
||||
): string {
|
||||
const hashSource = JSON.stringify({
|
||||
referenceData,
|
||||
dropdownOptions,
|
||||
itemProcessMappings: itemProcessMappings?.map(m => ({
|
||||
itemCode: m.itemCode,
|
||||
processes: m.processes.map(p => p.name),
|
||||
})),
|
||||
});
|
||||
return generateHash(hashSource);
|
||||
}
|
||||
327
frontend/components/common/SmartExcelUpload/templateParser.ts
Normal file
327
frontend/components/common/SmartExcelUpload/templateParser.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* SmartExcelUpload — 템플릿 파서
|
||||
* 업로드된 엑셀 파일 파싱 + 해시 검증 + 데이터 검증
|
||||
*/
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
import type {
|
||||
SmartExcelUploadConfig,
|
||||
ParseResult,
|
||||
ParsedSheetData,
|
||||
ValidationError,
|
||||
ConditionalRule,
|
||||
ItemProcessMapping,
|
||||
} from "./types";
|
||||
import { regenerateHash } from "./templateGenerator";
|
||||
|
||||
export interface ParseOptions {
|
||||
config: SmartExcelUploadConfig;
|
||||
file: File;
|
||||
/** 현재 DB의 참조 데이터 (해시 검증용) */
|
||||
currentReferenceData: Record<string, any>[];
|
||||
/** 현재 DB의 드롭다운 옵션 (해시 검증용) */
|
||||
currentDropdownOptions: Record<string, string[]>;
|
||||
/** 현재 DB의 품목공정 매핑 (해시 검증용) */
|
||||
currentItemProcessMappings?: ItemProcessMapping[];
|
||||
/** 참조 데이터 매핑 (라벨→코드 변환 등) */
|
||||
labelToCodeMap?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
/** 셀 값을 문자열로 안전 변환 */
|
||||
function cellToString(cell: ExcelJS.Cell): string {
|
||||
const val = cell.value;
|
||||
if (val === null || val === undefined) return "";
|
||||
|
||||
// 수식 객체
|
||||
if (typeof val === "object" && "formula" in val) {
|
||||
if ("result" in val) {
|
||||
return String((val as any).result ?? "");
|
||||
}
|
||||
// 수식만 있고 결과 없음 (미계산) → 빈 문자열
|
||||
return "";
|
||||
}
|
||||
// RichText
|
||||
if (typeof val === "object" && "richText" in val) {
|
||||
return (val as any).richText.map((r: any) => r.text).join("");
|
||||
}
|
||||
|
||||
return String(val).trim();
|
||||
}
|
||||
|
||||
/** _meta 시트에서 메타 정보 읽기 */
|
||||
function readMeta(
|
||||
workbook: ExcelJS.Workbook
|
||||
): Record<string, string> | null {
|
||||
const metaSheet = workbook.getWorksheet("_meta");
|
||||
if (!metaSheet) return null;
|
||||
|
||||
const meta: Record<string, string> = {};
|
||||
metaSheet.eachRow((row) => {
|
||||
const key = cellToString(row.getCell(1));
|
||||
const value = cellToString(row.getCell(2));
|
||||
if (key) meta[key] = value;
|
||||
});
|
||||
return meta;
|
||||
}
|
||||
|
||||
/** 엑셀 파일 파싱 + 검증 */
|
||||
export async function parseTemplate(
|
||||
options: ParseOptions
|
||||
): Promise<ParseResult> {
|
||||
const {
|
||||
config,
|
||||
file,
|
||||
currentReferenceData,
|
||||
currentDropdownOptions,
|
||||
currentItemProcessMappings,
|
||||
labelToCodeMap,
|
||||
} = options;
|
||||
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: string[] = [];
|
||||
const data: ParsedSheetData[] = [];
|
||||
|
||||
// 파일 읽기
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(arrayBuffer);
|
||||
|
||||
// ═══════════════════ 1. 메타 검증 ═══════════════════
|
||||
const meta = readMeta(workbook);
|
||||
if (!meta) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
errors: [],
|
||||
warnings: [
|
||||
"공식 템플릿이 아닙니다. 템플릿을 다운로드 후 사용해주세요.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// 해시 검증
|
||||
const currentHash = regenerateHash(
|
||||
currentReferenceData,
|
||||
currentDropdownOptions,
|
||||
currentItemProcessMappings
|
||||
);
|
||||
if (meta.version_hash && meta.version_hash !== currentHash) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
errors: [],
|
||||
warnings: [
|
||||
"기준 데이터가 변경되었습니다. 최신 템플릿을 다시 다운로드해주세요.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════ 2. 시트별 파싱 ═══════════════════
|
||||
for (const sheetConfig of config.sheets) {
|
||||
const ws = workbook.getWorksheet(sheetConfig.name);
|
||||
if (!ws) continue;
|
||||
|
||||
const rows: Record<string, any>[] = [];
|
||||
|
||||
// 헤더 확인 (1행)
|
||||
const headerRow = ws.getRow(1);
|
||||
const colMap: Record<number, string> = {};
|
||||
headerRow.eachCell((cell, colNumber) => {
|
||||
const label = cellToString(cell);
|
||||
const col = sheetConfig.columns.find((c) => c.label === label);
|
||||
if (col) colMap[colNumber] = col.key;
|
||||
});
|
||||
|
||||
// 사용자 입력 컬럼만 추출 (autoFill/readOnly/customFormula 제외)
|
||||
const userInputKeys = new Set(
|
||||
sheetConfig.columns
|
||||
.filter((c) => !c.readOnly && !c.autoFill && !c.customFormula)
|
||||
.map((c) => c.key)
|
||||
);
|
||||
|
||||
// 데이터 행 파싱 (2행부터)
|
||||
ws.eachRow((row, rowNumber) => {
|
||||
if (rowNumber <= 1) return;
|
||||
|
||||
// 빈 행 체크: 사용자 입력 컬럼만 확인 (수식 셀 무시)
|
||||
let hasData = false;
|
||||
for (const [colNum, key] of Object.entries(colMap)) {
|
||||
if (!userInputKeys.has(key)) continue;
|
||||
const val = cellToString(row.getCell(Number(colNum)));
|
||||
if (val) { hasData = true; break; }
|
||||
}
|
||||
if (!hasData) return;
|
||||
|
||||
const rowData: Record<string, any> = {};
|
||||
for (const [colNum, key] of Object.entries(colMap)) {
|
||||
let value = cellToString(row.getCell(Number(colNum)));
|
||||
|
||||
// 라벨→코드 변환
|
||||
if (labelToCodeMap?.[key] && value) {
|
||||
const code = labelToCodeMap[key][value];
|
||||
if (code) {
|
||||
rowData[`${key}_label`] = value;
|
||||
value = code;
|
||||
}
|
||||
}
|
||||
|
||||
rowData[key] = value;
|
||||
}
|
||||
|
||||
// ── 행별 검증 ──
|
||||
// 필수값 검증
|
||||
for (const col of sheetConfig.columns) {
|
||||
if (col.readOnly || col.autoFill || col.customFormula) continue;
|
||||
|
||||
const value = rowData[col.key];
|
||||
const isEmpty = !value && value !== 0;
|
||||
|
||||
// 조건부 규칙 적용
|
||||
// when.column이 autoFill/수식 컬럼이면 값이 미계산일 수 있으므로
|
||||
// labelToCodeMap 역매핑 또는 참조데이터에서 직접 조회
|
||||
if (config.conditionalRules) {
|
||||
const resolveCondValue = (colKey: string): string => {
|
||||
// 직접 입력된 값이 있으면 사용
|
||||
if (rowData[colKey]) return rowData[colKey];
|
||||
// autoFill 컬럼이면: lookup 기준 컬럼의 값으로 참조데이터에서 조회
|
||||
const colDef = sheetConfig.columns.find(c => c.key === colKey);
|
||||
if (colDef?.autoFill && currentReferenceData.length > 0) {
|
||||
const lookupVal = rowData[colDef.autoFill.lookupColumn];
|
||||
if (lookupVal) {
|
||||
const refRow = currentReferenceData.find(r =>
|
||||
r[config.referenceSheet?.columns?.[0]?.key || "label"] === lookupVal
|
||||
);
|
||||
if (refRow) return refRow[colDef.autoFill.referenceColumn] || "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const applicableRules = config.conditionalRules.filter(
|
||||
(rule) => resolveCondValue(rule.when.column) === rule.when.equals
|
||||
);
|
||||
|
||||
for (const rule of applicableRules) {
|
||||
// 필수 컬럼 체크
|
||||
if (rule.require.includes(col.key) && isEmpty) {
|
||||
const condLabel = sheetConfig.columns.find(c => c.key === rule.when.column)?.label || rule.when.column;
|
||||
errors.push({
|
||||
sheet: sheetConfig.name,
|
||||
row: rowNumber,
|
||||
column: col.label,
|
||||
message: `${condLabel}이(가) "${rule.when.equals}"일 때 ${col.label}은(는) 필수입니다`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 무시 컬럼이면 검증 스킵
|
||||
const isIgnored = applicableRules.some((rule) =>
|
||||
rule.ignore.includes(col.key)
|
||||
);
|
||||
if (isIgnored) continue;
|
||||
}
|
||||
|
||||
// 일반 필수값 검증
|
||||
if (col.required && isEmpty) {
|
||||
errors.push({
|
||||
sheet: sheetConfig.name,
|
||||
row: rowNumber,
|
||||
column: col.label,
|
||||
message: `${col.label}은(는) 필수 항목입니다`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 드롭다운 값 유효성 검증
|
||||
for (const col of sheetConfig.columns) {
|
||||
if (col.type !== "dropdown" || col.readOnly || col.autoFill) continue;
|
||||
|
||||
const value = rowData[col.key];
|
||||
if (!value) continue;
|
||||
|
||||
const optionKey = `${sheetConfig.name}:${col.key}`;
|
||||
const globalKey = col.key;
|
||||
const validValues =
|
||||
col.dropdown?.values ||
|
||||
currentDropdownOptions[optionKey] ||
|
||||
currentDropdownOptions[globalKey] ||
|
||||
[];
|
||||
|
||||
if (validValues.length > 0 && !validValues.includes(value)) {
|
||||
// 라벨로 입력한 경우도 체크
|
||||
const labelValue = rowData[`${col.key}_label`] || value;
|
||||
if (!validValues.includes(labelValue)) {
|
||||
errors.push({
|
||||
sheet: sheetConfig.name,
|
||||
row: rowNumber,
|
||||
column: col.label,
|
||||
message: `유효하지 않은 값: "${value}". 가능한 값: ${validValues.slice(0, 5).join(", ")}${validValues.length > 5 ? " ..." : ""}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// INDIRECT 드롭다운 유효성 검증 (품목별 공정 등)
|
||||
if (currentItemProcessMappings && currentItemProcessMappings.length > 0) {
|
||||
for (const col of sheetConfig.columns) {
|
||||
if (col.dropdown?.source !== "indirect" || col.dropdown.indirectPrefix === "ACC_") continue;
|
||||
const value = rowData[col.key];
|
||||
if (!value) continue;
|
||||
|
||||
// 품목코드 resolve
|
||||
const keyColName = col.dropdown.indirectKeyColumn || "";
|
||||
let itemCode = rowData[keyColName] || "";
|
||||
// customFormula 컬럼이면 수식 결과가 없을 수 있음 → 품목명으로 역매핑
|
||||
if (!itemCode) {
|
||||
// 사용자가 입력한 컬럼 중 itemProcessMappings의 itemName과 매칭되는 값 찾기
|
||||
for (const [, val] of Object.entries(rowData)) {
|
||||
if (val && typeof val === "string") {
|
||||
const mapping = currentItemProcessMappings.find(m => m.itemName === val);
|
||||
if (mapping) { itemCode = mapping.itemCode; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (itemCode) {
|
||||
const mapping = currentItemProcessMappings.find(m => m.itemCode === itemCode);
|
||||
if (mapping) {
|
||||
const valid = mapping.processes.some(p => p.code === value || p.name === value);
|
||||
if (!valid) {
|
||||
const displayValue = rowData[`${col.key}_label`] || mapping.processes.find(p => p.code === value)?.name || value;
|
||||
const validNames = mapping.processes.map(p => p.name);
|
||||
errors.push({
|
||||
sheet: sheetConfig.name,
|
||||
row: rowNumber,
|
||||
column: col.label,
|
||||
message: `"${displayValue}"은(는) 해당 품목의 유효한 공정이 아닙니다${validNames.length > 0 ? `. 가능한 공정: ${validNames.join(", ")}` : ""}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.push(rowData);
|
||||
});
|
||||
|
||||
if (rows.length > 0) {
|
||||
data.push({
|
||||
sheetName: sheetConfig.name,
|
||||
typeKey: sheetConfig.typeKey,
|
||||
rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 없음 체크
|
||||
if (data.length === 0 || data.every((d) => d.rows.length === 0)) {
|
||||
warnings.push("업로드할 데이터가 없습니다. 시트에 데이터를 입력해주세요.");
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0 && warnings.length === 0,
|
||||
data,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
151
frontend/components/common/SmartExcelUpload/types.ts
Normal file
151
frontend/components/common/SmartExcelUpload/types.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* SmartExcelUpload — 설정 기반 엑셀 업로드 공통 모듈 타입 정의
|
||||
*/
|
||||
|
||||
/** 드롭다운 소스 설정 */
|
||||
export interface DropdownConfig {
|
||||
/** 드롭다운 소스 유형 */
|
||||
source: "category" | "custom" | "reference" | "indirect";
|
||||
/** 카테고리 조회 시 테이블명 */
|
||||
tableName?: string;
|
||||
/** 카테고리 조회 시 컬럼명 */
|
||||
columnName?: string;
|
||||
/** 고정 값 목록 (source: "custom") */
|
||||
values?: string[];
|
||||
/** 참조시트 컬럼에서 가져올 때 (source: "reference") — VLOOKUP 기반 */
|
||||
referenceColumn?: string;
|
||||
/** INDIRECT 연동 시 참조할 컬럼 키 (source: "indirect") — 품목별 공정 등 */
|
||||
indirectKeyColumn?: string;
|
||||
/** INDIRECT 이름 범위 prefix (source: "indirect") — 예: "ACC_" → INDIRECT("ACC_" & 셀값) */
|
||||
indirectPrefix?: string;
|
||||
}
|
||||
|
||||
/** 컬럼 정의 */
|
||||
export interface SmartColumn {
|
||||
/** DB 컬럼명 */
|
||||
key: string;
|
||||
/** 엑셀 헤더 라벨 */
|
||||
label: string;
|
||||
/** 필수 여부 */
|
||||
required?: boolean;
|
||||
/** 데이터 타입 */
|
||||
type: "text" | "number" | "date" | "dropdown";
|
||||
/** 드롭다운 설정 */
|
||||
dropdown?: DropdownConfig;
|
||||
/** VLOOKUP 자동 입력 (참조시트에서 자동으로 값 가져옴, 사용자 입력 불가) */
|
||||
autoFill?: {
|
||||
/** 참조시트에서 lookup할 키 컬럼 (같은 시트의 어떤 컬럼 값을 기준으로) */
|
||||
lookupColumn: string;
|
||||
/** 참조시트에서 가져올 컬럼명 */
|
||||
referenceColumn: string;
|
||||
};
|
||||
/** 조건부 활성화 — 다른 컬럼 값이 일치할 때만 입력 가능 */
|
||||
enableWhen?: {
|
||||
/** 참조할 컬럼 키 */
|
||||
column: string;
|
||||
/** 해당 값일 때만 입력 가능 */
|
||||
equals: string;
|
||||
};
|
||||
/** 조건부 비활성화 — 다른 컬럼 값이 일치하면 입력 차단 */
|
||||
disableWhen?: {
|
||||
/** 참조할 컬럼 키 */
|
||||
column: string;
|
||||
/** 해당 값일 때 입력 차단 */
|
||||
equals: string;
|
||||
};
|
||||
/** 커스텀 수식 — {col:key} 형태로 같은 행의 다른 컬럼 셀 참조 가능 */
|
||||
customFormula?: string;
|
||||
/** 읽기전용 (자동채움 등) */
|
||||
readOnly?: boolean;
|
||||
/** 컬럼 너비 (기본 18) */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
/** 시트 정의 */
|
||||
export interface SheetConfig {
|
||||
/** 시트명 (예: "수입검사") */
|
||||
name: string;
|
||||
/** DB 저장 시 타입 구분 값 (예: "수입검사") */
|
||||
typeKey?: string;
|
||||
/** 컬럼 정의 */
|
||||
columns: SmartColumn[];
|
||||
}
|
||||
|
||||
/** 참조시트 정의 — 검사기준정보 등 lookup 데이터 */
|
||||
export interface ReferenceSheetConfig {
|
||||
/** 시트명 */
|
||||
name: string;
|
||||
/** 컬럼 정의 */
|
||||
columns: { key: string; label: string }[];
|
||||
/** 참조 데이터 (런타임에 주입) */
|
||||
data?: Record<string, any>[];
|
||||
}
|
||||
|
||||
/** 조건부 검증 규칙 */
|
||||
export interface ConditionalRule {
|
||||
/** 조건: 어떤 컬럼이 어떤 값일 때 */
|
||||
when: { column: string; equals: string };
|
||||
/** 필수가 되는 컬럼들 */
|
||||
require: string[];
|
||||
/** 무시해도 되는 컬럼들 (비어있어도 OK) */
|
||||
ignore: string[];
|
||||
}
|
||||
|
||||
/** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */
|
||||
export interface ItemProcessMapping {
|
||||
/** 품목코드 */
|
||||
itemCode: string;
|
||||
/** 품목명 */
|
||||
itemName: string;
|
||||
/** 해당 품목의 공정 목록 */
|
||||
processes: { code: string; name: string }[];
|
||||
}
|
||||
|
||||
/** INDIRECT prefix(ACC_) 기반 동적 드롭다운 옵션 정의 */
|
||||
export interface IndirectOptionsConfig {
|
||||
/** 참조시트에서 조건을 판단할 컬럼 키 (예: "judgment_criteria") */
|
||||
conditionColumn: string;
|
||||
/** 조건값→옵션 매핑 */
|
||||
optionsByCondition: Record<string, string[]>;
|
||||
/** 참조시트에서 선택옵션을 가져올 컬럼 키 (콤마 구분 문자열, 예: "selection_options") */
|
||||
selectionOptionsColumn?: string;
|
||||
}
|
||||
|
||||
/** 전체 설정 */
|
||||
export interface SmartExcelUploadConfig {
|
||||
/** 템플릿 파일명 (예: "품목검사정보") */
|
||||
templateName: string;
|
||||
/** 시트 정의 목록 */
|
||||
sheets: SheetConfig[];
|
||||
/** 참조시트 설정 (없으면 생략) */
|
||||
referenceSheet?: ReferenceSheetConfig;
|
||||
/** 조건부 검증 규칙 (없으면 단순 필수값 체크만) */
|
||||
conditionalRules?: ConditionalRule[];
|
||||
/** INDIRECT ACC_ 동적 드롭다운 옵션 설정 (없으면 ACC_ 시트 미생성) */
|
||||
indirectOptions?: IndirectOptionsConfig;
|
||||
/** 추가 메타 정보 (템플릿에 포함) */
|
||||
extraMeta?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 파싱 결과 — 시트별 데이터 */
|
||||
export interface ParsedSheetData {
|
||||
sheetName: string;
|
||||
typeKey?: string;
|
||||
rows: Record<string, any>[];
|
||||
}
|
||||
|
||||
/** 검증 에러 */
|
||||
export interface ValidationError {
|
||||
sheet: string;
|
||||
row: number;
|
||||
column: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 파싱 + 검증 결과 */
|
||||
export interface ParseResult {
|
||||
success: boolean;
|
||||
data: ParsedSheetData[];
|
||||
errors: ValidationError[];
|
||||
warnings: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user