summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx6
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx4
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts1
-rw-r--r--lib/vendor-document-list/plant/excel-import-stage copy 2.tsx899
-rw-r--r--lib/vendor-document-list/plant/excel-import-stage copy.tsx908
-rw-r--r--lib/vendor-document-list/plant/excel-import-stage.tsx6
6 files changed, 8 insertions, 1816 deletions
diff --git a/lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx b/lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx
index 08e73a36..10300d7c 100644
--- a/lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx
+++ b/lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx
@@ -78,7 +78,7 @@ export function DeleteDocumentClassDialog({
for (const documentClass of documentClasses) {
const result = await deleteDocumentClassCodeGroup(documentClass.id)
if (!result.success) {
- toast.error(`Document Class ${documentClass.code} 삭제 실패: ${result.error}`)
+ toast.error(`Document Class ${documentClass.value} 삭제 실패: ${result.error}`)
return
}
}
@@ -124,7 +124,7 @@ export function DeleteDocumentClassDialog({
.filter(docClass => optionsCounts[docClass.id] > 0)
.map(docClass => (
<li key={docClass.id}>
- {docClass.code} ({optionsCounts[docClass.id]}개 옵션)
+ {docClass.value} ({optionsCounts[docClass.id]}개 옵션)
</li>
))}
</ul>
@@ -187,7 +187,7 @@ export function DeleteDocumentClassDialog({
.filter(docClass => optionsCounts[docClass.id] > 0)
.map(docClass => (
<li key={docClass.id}>
- {docClass.code} ({optionsCounts[docClass.id]}개 옵션)
+ {docClass.value} ({optionsCounts[docClass.id]}개 옵션)
</li>
))}
</ul>
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
index 0c972658..d4e0ff33 100644
--- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx
+++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
@@ -466,7 +466,7 @@ export function AddDocumentDialog({
} else if (config.documentClass) {
return (
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded text-sm">
- {config.documentClass.code} - {config.documentClass.description}
+ {config.documentClass.value} - {config.documentClass.description}
</div>
)
} else {
@@ -630,7 +630,7 @@ export function AddDocumentDialog({
Options from the selected class will be automatically created as stages.
{(() => {
const selected = documentClasses.find(cls => String(cls.id) === documentClassId)
- return selected ? ` (Selected: ${selected.code})` : ''
+ return selected ? ` (Selected: ${selected.value})` : ''
})()}
</p>
)}
diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts
index 1a7b1ab6..7740a385 100644
--- a/lib/vendor-document-list/plant/document-stages-service.ts
+++ b/lib/vendor-document-list/plant/document-stages-service.ts
@@ -803,7 +803,6 @@ export async function getDocumentClassOptionsByContract(contractId: number) {
const classes = await db
.select({
id: documentClasses.id,
- code: documentClasses.code,
value: documentClasses.value,
description: documentClasses.description,
})
diff --git a/lib/vendor-document-list/plant/excel-import-stage copy 2.tsx b/lib/vendor-document-list/plant/excel-import-stage copy 2.tsx
deleted file mode 100644
index 8dc85c51..00000000
--- a/lib/vendor-document-list/plant/excel-import-stage copy 2.tsx
+++ /dev/null
@@ -1,899 +0,0 @@
-"use client"
-
-import React from "react"
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Progress } from "@/components/ui/progress"
-import { Alert, AlertDescription } from "@/components/ui/alert"
-import { Upload, FileSpreadsheet, Loader2, Download, CheckCircle, AlertCircle } from "lucide-react"
-import { toast } from "sonner"
-import { useRouter } from "next/navigation"
-import ExcelJS from "exceljs"
-import {
- getDocumentClassOptionsByContract,
- uploadImportData,
-} from "./document-stages-service"
-
-// =============================================================================
-// Type Definitions
-// =============================================================================
-interface ExcelImportDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- contractId: number
- projectType: "ship" | "plant"
-}
-
-interface ImportResult {
- documents: any[]
- stages: any[]
- errors: string[]
- warnings: string[]
-}
-
-interface ParsedDocument {
- docNumber: string
- title: string
- documentClass: string
- vendorDocNumber?: string
- notes?: string
- stages?: { stageName: string; planDate: string }[]
-}
-
-// =============================================================================
-// Main Component
-// =============================================================================
-export function ExcelImportDialog({
- open,
- onOpenChange,
- contractId,
- projectType
-}: ExcelImportDialogProps) {
- const [file, setFile] = React.useState<File | null>(null)
- const [isProcessing, setIsProcessing] = React.useState(false)
- const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false)
- const [importResult, setImportResult] = React.useState<ImportResult | null>(null)
- const [processStep, setProcessStep] = React.useState<string>("")
- const [progress, setProgress] = React.useState(0)
- const router = useRouter()
-
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- const selectedFile = e.target.files?.[0]
- if (selectedFile) {
- if (!validateFileExtension(selectedFile)) {
- toast.error("Excel 파일(.xlsx, .xls)만 업로드 가능합니다.")
- return
- }
- if (!validateFileSize(selectedFile, 10)) {
- toast.error("파일 크기는 10MB 이하여야 합니다.")
- return
- }
- setFile(selectedFile)
- setImportResult(null)
- }
- }
-
- const handleDownloadTemplate = async () => {
- setIsDownloadingTemplate(true)
- try {
- const workbook = await createImportTemplate(projectType, contractId)
- const buffer = await workbook.xlsx.writeBuffer()
- const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
- const url = window.URL.createObjectURL(blob)
- const link = document.createElement("a")
- link.href = url
- link.download = `문서_임포트_템플릿_${projectType}_${new Date().toISOString().split("T")[0]}.xlsx`
- link.click()
- window.URL.revokeObjectURL(url)
- toast.success("템플릿 파일이 다운로드되었습니다.")
- } catch (error) {
- toast.error("템플릿 다운로드 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : "알 수 없는 오류"))
- } finally {
- setIsDownloadingTemplate(false)
- }
- }
-
- const handleImport = async () => {
- if (!file) {
- toast.error("파일을 선택해주세요.")
- return
- }
- setIsProcessing(true)
- setProgress(0)
- try {
- setProcessStep("파일 읽는 중...")
- setProgress(20)
- const workbook = new ExcelJS.Workbook()
- const buffer = await file.arrayBuffer()
- await workbook.xlsx.load(buffer)
-
- setProcessStep("데이터 검증 중...")
- setProgress(40)
- const worksheet = workbook.getWorksheet("Documents") || workbook.getWorksheet(1)
- if (!worksheet) throw new Error("Documents 시트를 찾을 수 없습니다.")
-
- setProcessStep("문서 및 스테이지 데이터 파싱 중...")
- setProgress(60)
- const parseResult = await parseDocumentsWithStages(worksheet, projectType, contractId)
-
- setProcessStep("서버에 업로드 중...")
- setProgress(90)
- const allStages: any[] = []
- parseResult.validData.forEach((doc) => {
- if (doc.stages) {
- doc.stages.forEach((stage) => {
- allStages.push({
- docNumber: doc.docNumber,
- stageName: stage.stageName,
- planDate: stage.planDate,
- })
- })
- }
- })
-
- const result = await uploadImportData({
- contractId,
- documents: parseResult.validData,
- stages: allStages,
- projectType,
- })
-
- if (result.success) {
- setImportResult({
- documents: parseResult.validData,
- stages: allStages,
- errors: parseResult.errors,
- warnings: result.warnings || [],
- })
- setProgress(100)
- toast.success(`${parseResult.validData.length}개 문서가 성공적으로 임포트되었습니다.`)
- } else {
- throw new Error(result.error || "임포트에 실패했습니다.")
- }
- } catch (error) {
- toast.error(error instanceof Error ? error.message : "임포트 중 오류가 발생했습니다.")
- setImportResult({
- documents: [],
- stages: [],
- errors: [error instanceof Error ? error.message : "알 수 없는 오류"],
- warnings: [],
- })
- } finally {
- setIsProcessing(false)
- setProcessStep("")
- setProgress(0)
- }
- }
-
- const handleClose = () => {
- setFile(null)
- setImportResult(null)
- setProgress(0)
- setProcessStep("")
- onOpenChange(false)
- }
-
- const handleConfirmImport = () => {
- router.refresh()
- handleClose()
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
- <DialogHeader className="flex-shrink-0">
- <DialogTitle>
- <FileSpreadsheet className="inline w-5 h-5 mr-2" />
- Excel 파일 임포트
- </DialogTitle>
- <DialogDescription>
- Excel 파일을 사용하여 문서와 스테이지 계획을 일괄 등록합니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="flex-1 overflow-y-auto pr-2">
- <div className="grid gap-4 py-4">
- {/* 템플릿 다운로드 섹션 */}
- <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30">
- <h4 className="font-medium text-blue-800 dark:text-blue-200 mb-2">1. 템플릿 다운로드</h4>
- <p className="text-sm text-blue-700 dark:text-blue-300 mb-3">
- 올바른 형식과 스마트 검증이 적용된 템플릿을 다운로드하세요.
- </p>
- <Button variant="outline" size="sm" onClick={handleDownloadTemplate} disabled={isDownloadingTemplate}>
- {isDownloadingTemplate ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Download className="h-4 w-4 mr-2" />}
- 템플릿 다운로드
- </Button>
- </div>
-
- {/* 파일 업로드 섹션 */}
- <div className="border rounded-lg p-4">
- <h4 className="font-medium mb-2">2. 파일 업로드</h4>
- <div className="grid gap-2">
- <Label htmlFor="excel-file">Excel 파일 선택</Label>
- <Input
- id="excel-file"
- type="file"
- accept=".xlsx,.xls"
- onChange={handleFileChange}
- disabled={isProcessing}
- className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
- />
- {file && (
- <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
- 선택된 파일: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
- </p>
- )}
- </div>
- </div>
-
- {/* 진행 상태 */}
- {isProcessing && (
- <div className="border rounded-lg p-4 bg-yellow-50/30 dark:bg-yellow-950/30">
- <div className="flex items-center gap-2 mb-2">
- <Loader2 className="h-4 w-4 animate-spin text-yellow-600" />
- <span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">처리 중...</span>
- </div>
- <p className="text-sm text-yellow-700 dark:text-yellow-300 mb-2">{processStep}</p>
- <Progress value={progress} className="h-2" />
- </div>
- )}
-
- {/* 임포트 결과 */}
- {importResult && <ImportResultDisplay importResult={importResult} />}
-
- {/* 파일 형식 가이드 */}
- <FileFormatGuide projectType={projectType} />
- </div>
- </div>
-
- <DialogFooter className="flex-shrink-0">
- <Button variant="outline" onClick={handleClose}>
- {importResult ? "닫기" : "취소"}
- </Button>
- {!importResult ? (
- <Button onClick={handleImport} disabled={!file || isProcessing}>
- {isProcessing ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />}
- {isProcessing ? "처리 중..." : "임포트 시작"}
- </Button>
- ) : importResult.documents.length > 0 ? (
- <Button onClick={handleConfirmImport}>완료 및 새로고침</Button>
- ) : null}
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
-
-// =============================================================================
-// Sub Components
-// =============================================================================
-function ImportResultDisplay({ importResult }: { importResult: ImportResult }) {
- return (
- <div className="space-y-3">
- {importResult.documents.length > 0 && (
- <Alert>
- <CheckCircle className="h-4 w-4" />
- <AlertDescription>
- <strong>{importResult.documents.length}개</strong> 문서가 성공적으로 임포트되었습니다.
- {importResult.stages.length > 0 && <> ({importResult.stages.length}개 스테이지 계획날짜 포함)</>}
- </AlertDescription>
- </Alert>
- )}
-
- {importResult.warnings.length > 0 && (
- <Alert>
- <AlertCircle className="h-4 w-4" />
- <AlertDescription>
- <strong>경고:</strong>
- <ul className="mt-1 list-disc list-inside">
- {importResult.warnings.map((warning, index) => (
- <li key={index} className="text-sm">
- {warning}
- </li>
- ))}
- </ul>
- </AlertDescription>
- </Alert>
- )}
-
- {importResult.errors.length > 0 && (
- <Alert variant="destructive">
- <AlertCircle className="h-4 w-4" />
- <AlertDescription>
- <strong>오류:</strong>
- <ul className="mt-1 list-disc list-inside">
- {importResult.errors.map((error, index) => (
- <li key={index} className="text-sm">
- {error}
- </li>
- ))}
- </ul>
- </AlertDescription>
- </Alert>
- )}
- </div>
- )
-}
-
-function FileFormatGuide({ projectType }: { projectType: "ship" | "plant" }) {
- return (
- <div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
- <h4 className="font-medium text-gray-800 dark:text-gray-200 mb-2">파일 형식 가이드</h4>
- <div className="text-sm text-gray-700 dark:text-gray-300 space-y-1">
- <p>
- <strong>통합 Documents 시트:</strong>
- </p>
- <ul className="ml-4 list-disc">
- <li>Document Number* (문서번호)</li>
- <li>Document Name* (문서명)</li>
- <li>Document Class* (문서클래스 - 드롭다운 선택)</li>
- <li>Project Doc No.* (프로젝트 문서번호)</li>
- <li>각 Stage Name 컬럼 (계획날짜 입력: YYYY-MM-DD)</li>
- </ul>
- <p className="mt-2 text-green-600 dark:text-green-400">
- <strong>스마트 검증 기능:</strong>
- </p>
- <ul className="ml-4 list-disc text-green-600 dark:text-green-400">
- <li>Document Class 드롭다운으로 정확한 값 선택</li>
- <li>선택한 Class에 맞지 않는 Stage는 자동으로 회색 처리</li>
- <li>잘못된 Stage에 날짜 입력시 빨간색으로 경고</li>
- <li>날짜 형식 자동 검증</li>
- </ul>
- <p className="mt-2 text-yellow-600 dark:text-yellow-400">
- <strong>색상 가이드:</strong>
- </p>
- <ul className="ml-4 list-disc text-yellow-600 dark:text-yellow-400">
- <li>🟦 파란색 헤더: 필수 입력 항목</li>
- <li>🟩 초록색 헤더: 해당 Class의 유효한 Stage</li>
- <li>⬜ 회색 셀: 해당 Class에서 사용 불가능한 Stage</li>
- <li>🟥 빨간색 셀: 잘못된 입력 (검증 실패)</li>
- </ul>
- <p className="mt-2 text-red-600 dark:text-red-400">* 필수 항목</p>
- </div>
- </div>
- )
-}
-
-// =============================================================================
-// Helper Functions
-// =============================================================================
-function validateFileExtension(file: File): boolean {
- const allowedExtensions = [".xlsx", ".xls"]
- const fileName = file.name.toLowerCase()
- return allowedExtensions.some((ext) => fileName.endsWith(ext))
-}
-
-function validateFileSize(file: File, maxSizeMB: number): boolean {
- const maxSizeBytes = maxSizeMB * 1024 * 1024
- return file.size <= maxSizeBytes
-}
-
-function getExcelColumnName(index: number): string {
- let result = ""
- while (index > 0) {
- index--
- result = String.fromCharCode(65 + (index % 26)) + result
- index = Math.floor(index / 26)
- }
- return result
-}
-
-function styleHeaderRow(
- headerRow: ExcelJS.Row,
- bgColor: string = "FF4472C4",
- startCol?: number,
- endCol?: number
-) {
- const start = startCol || 1
- const end = endCol || headerRow.cellCount
-
- for (let i = start; i <= end; i++) {
- const cell = headerRow.getCell(i)
- cell.fill = {
- type: "pattern",
- pattern: "solid",
- fgColor: { argb: bgColor },
- }
- cell.font = {
- color: { argb: "FFFFFFFF" },
- bold: true,
- }
- cell.alignment = {
- horizontal: "center",
- vertical: "middle",
- }
- cell.border = {
- top: { style: "thin" },
- left: { style: "thin" },
- bottom: { style: "thin" },
- right: { style: "thin" },
- }
- }
- headerRow.height = 20
-}
-
-// =============================================================================
-// Template Creation - 통합 시트 + 조건부서식/검증
-// =============================================================================
-async function createImportTemplate(projectType: "ship" | "plant", contractId: number) {
- const res = await getDocumentClassOptionsByContract(contractId)
- if (!res.success) throw new Error(res.error || "데이터 로딩 실패")
-
- const documentClasses = res.data.classes as Array<{ id: number; code: string; description: string }>
- const options = res.data.options as Array<{ documentClassId: number; optionValue: string }>
-
- // 클래스별 옵션 맵
- const optionsByClassId = new Map<number, string[]>()
- for (const c of documentClasses) optionsByClassId.set(c.id, [])
- for (const o of options) optionsByClassId.get(o.documentClassId)!.push(o.optionValue)
-
- // 유니크 Stage
- const allStageNames = Array.from(new Set(options.map((o) => o.optionValue)))
-
- const workbook = new ExcelJS.Workbook()
- // 파일 열 때 강제 전체 계산
- workbook.calcProperties.fullCalcOnLoad = true
-
- // ================= Documents 시트 =================
- const worksheet = workbook.addWorksheet("Documents")
-
- const headers = [
- "Document Number*",
- "Document Name*",
- "Document Class*",
- ...(projectType === "plant" ? ["Project Doc No.*"] : []),
- ...allStageNames,
- ]
- const headerRow = worksheet.addRow(headers)
-
- // 필수 헤더 (파랑)
- const requiredCols = projectType === "plant" ? 4 : 3
- styleHeaderRow(headerRow, "FF4472C4", 1, requiredCols)
- // Stage 헤더 (초록)
- styleHeaderRow(headerRow, "FF27AE60", requiredCols + 1, headers.length)
-
- // 샘플 데이터
- const firstClass = documentClasses[0]
- const firstClassStages = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : []
- const sampleRow = [
- projectType === "ship" ? "SH-2024-001" : "PL-2024-001",
- "샘플 문서명",
- firstClass ? firstClass.description : "",
- ...(projectType === "plant" ? ["V-001"] : []),
- ...allStageNames.map((s) => (firstClassStages.includes(s) ? "2024-03-01" : "")),
- ]
- worksheet.addRow(sampleRow)
-
- const docNumberColIndex = 1; // A: Document Number*
- const docNameColIndex = 2; // B: Document Name*
- const docNumberColLetter = getExcelColumnName(docNumberColIndex);
- const docNameColLetter = getExcelColumnName(docNameColIndex);
-
- worksheet.dataValidations.add(`${docNumberColLetter}2:${docNumberColLetter}1000`, {
- type: "textLength",
- operator: "greaterThan",
- formulae: [0],
- allowBlank: false,
- showErrorMessage: true,
- errorTitle: "필수 입력",
- error: "Document Number는 필수 항목입니다.",
- });
-
- // 1) 빈값 금지 (길이 > 0)
- worksheet.dataValidations.add(`${docNumberColLetter}2:${docNumberColLetter}1000`, {
- type: "textLength",
- operator: "greaterThan",
- formulae: [0],
- allowBlank: false,
- showErrorMessage: true,
- errorTitle: "필수 입력",
- error: "Document Number는 필수 항목입니다.",
- });
-
-
- // 드롭다운: Document Class
- const docClassColIndex = 3 // "Document Class*"는 항상 3열
- const docClassColLetter = getExcelColumnName(docClassColIndex)
- worksheet.dataValidations.add(`${docClassColLetter}2:${docClassColLetter}1000`, {
- type: "list",
- allowBlank: false,
- formulae: [`ReferenceData!$A$2:$A${documentClasses.length + 1}`],
- showErrorMessage: true,
- errorTitle: "잘못된 입력",
- error: "드롭다운 목록에서 Document Class를 선택하세요.",
- })
-
- // 2) 중복 금지 (COUNTIF로 현재 값이 범위에서 1회만 등장해야 함)
- // - Validation은 한 셀에 1개만 가능하므로, 중복 검증은 "Custom" 하나로 통합하는 방법도 있음.
- // - 여기서는 '중복 금지'를 추가적으로 **Guidance용**으로 Conditional Formatting(빨간색)으로 가시화합니다.
- worksheet.addConditionalFormatting({
- ref: `${docNumberColLetter}2:${docNumberColLetter}1000`,
- rules: [
- // 빈값 빨간
- {
- type: "expression",
- formulae: [`LEN(${docNumberColLetter}2)=0`],
- style: {
- fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } },
- },
- },
- // 중복 빨간: COUNTIF($A$2:$A$1000, A2) > 1
- {
- type: "expression",
- formulae: [`COUNTIF($${docNumberColLetter}$2:$${docNumberColLetter}$1000,${docNumberColLetter}2)>1`],
- style: {
- fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } },
- },
- },
- ],
- });
-
-
- // ===== Document Name* (B열): 빈값 금지 + 빈칸 빨간 =====
-worksheet.dataValidations.add(`${docNameColLetter}2:${docNameColLetter}1000`, {
- type: "textLength",
- operator: "greaterThan",
- formulae: [0],
- allowBlank: false,
- showErrorMessage: true,
- errorTitle: "필수 입력",
- error: "Document Name은 필수 항목입니다.",
-});
-
-worksheet.addConditionalFormatting({
- ref: `${docNameColLetter}2:${docNameColLetter}1000`,
- rules: [
- {
- type: "expression",
- formulae: [`LEN(${docNameColLetter}2)=0`],
- style: {
- fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } },
- },
- },
- ],
-});
-
-// ===== Document Class* (C열): 드롭다운 + allowBlank:false로 차단은 되어 있음 → 빈칸 빨간만 추가 =====
-worksheet.addConditionalFormatting({
- ref: `${docClassColLetter}2:${docClassColLetter}1000`,
- rules: [
- {
- type: "expression",
- formulae: [`LEN(${docClassColLetter}2)=0`],
- style: {
- fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } },
- },
- },
- ],
-});
-
-// ===== Project Doc No.* (Plant 전용): (이미 작성하신 코드 유지) =====
-if (projectType === "plant") {
- const vendorDocColIndex = 4; // D
- const vendorDocColLetter = getExcelColumnName(vendorDocColIndex);
-
- worksheet.dataValidations.add(`${vendorDocColLetter}2:${vendorDocColLetter}1000`, {
- type: "textLength",
- operator: "greaterThan",
- formulae: [0],
- allowBlank: false,
- showErrorMessage: true,
- errorTitle: "필수 입력",
- error: "Project Doc No.는 필수 항목입니다.",
- });
-
- worksheet.addConditionalFormatting({
- ref: `${vendorDocColLetter}2:${vendorDocColLetter}1000`,
- rules: [
- {
- type: "expression",
- formulae: [`LEN(${vendorDocColLetter}2)=0`],
- style: {
- fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } },
- },
- },
- // 중복 빨간: COUNTIF($A$2:$A$1000, A2) > 1
- {
- type: "expression",
- formulae: [`COUNTIF($${vendorDocColLetter}$2:$${vendorDocColLetter}$1000,${vendorDocColLetter}2)>1`],
- style: {
- fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } },
- },
- },
- ],
- });
-
-}
-
- if (projectType === "plant") {
- const vendorDocColIndex = 4; // Document Number, Name, Class 다음이 Project Doc No.*
- const vendorDocColLetter = getExcelColumnName(vendorDocColIndex);
-
- // 공백 불가: 글자수 > 0
- worksheet.dataValidations.add(`${vendorDocColLetter}2:${vendorDocColLetter}1000`, {
- type: "textLength",
- operator: "greaterThan",
- formulae: [0],
- allowBlank: false,
- showErrorMessage: true,
- errorTitle: "필수 입력",
- error: "Project Doc No.는 필수 항목입니다.",
- });
-
- // UX: 비어있으면 빨간 배경으로 표시 (조건부 서식)
- worksheet.addConditionalFormatting({
- ref: `${vendorDocColLetter}2:${vendorDocColLetter}1000`,
- rules: [
- {
- type: "expression",
- formulae: [`LEN(${vendorDocColLetter}2)=0`],
- style: {
- fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, // 연한 빨강
- },
- },
- ],
- });
- }
-
- // 날짜 셀 형식 + 검증/조건부서식
- const stageStartCol = requiredCols + 1
- const stageEndCol = stageStartCol + allStageNames.length - 1
-
- // ================= 매트릭스 시트 (Class-Stage Matrix) =================
- const matrixSheet = workbook.addWorksheet("Class-Stage Matrix")
- const matrixHeaders = ["Document Class", ...allStageNames]
- const matrixHeaderRow = matrixSheet.addRow(matrixHeaders)
- styleHeaderRow(matrixHeaderRow, "FF34495E")
- for (const docClass of documentClasses) {
- const validStages = new Set(optionsByClassId.get(docClass.id) ?? [])
- const row = [docClass.description, ...allStageNames.map((stage) => (validStages.has(stage) ? "✓" : ""))]
- const dataRow = matrixSheet.addRow(row)
- allStageNames.forEach((stage, idx) => {
- const cell = dataRow.getCell(idx + 2)
- if (validStages.has(stage)) {
- cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFD4EDDA" } }
- cell.font = { color: { argb: "FF28A745" } }
- }
- })
- }
- matrixSheet.columns = [{ width: 30 }, ...allStageNames.map(() => ({ width: 15 }))]
-
- // 매트릭스 범위 계산 (B ~ 마지막 Stage 열)
- const matrixStageFirstColLetter = "B"
- const matrixStageLastColLetter = getExcelColumnName(1 + allStageNames.length) // 1:A, 2:B ... (A는 Class, B부터 Stage)
- const matrixClassCol = "$A:$A"
- const matrixHeaderRowRange = "$1:$1"
- const matrixBodyRange = `$${matrixStageFirstColLetter}:$${matrixStageLastColLetter}`
-
- // ================= 가이드 시트 =================
- const guideSheet = workbook.addWorksheet("사용 가이드")
- const guideContent: string[][] = [
- ["📋 통합 문서 임포트 가이드"],
- [""],
- ["1. 하나의 시트에서 모든 정보 관리"],
- [" • Document Number*: 고유한 문서 번호"],
- [" • Document Name*: 문서명"],
- [" • Document Class*: 드롭다운에서 선택"],
- ...(projectType === "plant" ? [[" • Project Doc No.: 벤더 문서 번호"]] : []),
- [" • Stage 컬럼들: 각 스테이지의 계획 날짜 (YYYY-MM-DD)"],
- [""],
- ["2. 스마트 검증 기능"],
- [" • Document Class를 선택하면 해당하지 않는 Stage는 자동으로 비활성화(회색)"],
- [" • 비유효 Stage에 날짜 입력 시 입력 자체가 막히고 경고 표시"],
- [" • 날짜 형식 자동 검증"],
- [""],
- ["3. Class-Stage Matrix 시트 활용"],
- [" • 각 Document Class별로 사용 가능한 Stage 확인"],
- [" • ✓ 표시가 있는 Stage만 해당 Class에서 사용 가능"],
- [""],
- ["4. 작성 순서"],
- [" ① Document Number, Name 입력"],
- [" ② Document Class 드롭다운에서 선택"],
- [" ③ Class-Stage Matrix 확인하여 유효한 Stage 파악"],
- [" ④ 해당 Stage 컬럼에만 날짜 입력"],
- [""],
- ["5. 주의사항"],
- [" • * 표시는 필수 항목"],
- [" • Document Number는 중복 불가"],
- [" • 해당 Class에 맞지 않는 Stage에 날짜 입력 시 무시/차단"],
- [" • 날짜는 YYYY-MM-DD 형식 준수"],
- ]
- guideContent.forEach((row, i) => {
- const r = guideSheet.addRow(row)
- if (i === 0) r.getCell(1).font = { bold: true, size: 14 }
- else if (row[0] && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true }
- })
- guideSheet.getColumn(1).width = 70
-
- // ================= ReferenceData (숨김) =================
- const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" })
- referenceSheet.getCell("A1").value = "DocumentClasses"
- documentClasses.forEach((dc, idx) => {
- referenceSheet.getCell(`A${idx + 2}`).value = dc.description
- })
-
- // ================= Stage 열별 서식/검증 =================
- // 문서 시트 컬럼 너비
- worksheet.columns = [
- { width: 18 }, // Doc Number
- { width: 30 }, // Doc Name
- { width: 30 }, // Doc Class
- ...(projectType === "plant" ? [{ width: 18 }] : []),
- ...allStageNames.map(() => ({ width: 12 })),
- ]
-
- // 각 Stage 열 처리
- for (let stageIdx = 0; stageIdx < allStageNames.length; stageIdx++) {
- const colIndex = stageStartCol + stageIdx
- const colLetter = getExcelColumnName(colIndex)
-
- // 날짜 표시 형식
- for (let row = 2; row <= 1000; row++) {
- worksheet.getCell(`${colLetter}${row}`).numFmt = "yyyy-mm-dd"
- }
-
- // ---- 커스텀 데이터 검증 (빈칸 OR (해당 Class에 유효한 Stage AND 숫자(=날짜))) ----
- // INDEX('Class-Stage Matrix'!$B:$ZZ, MATCH($C2,'Class-Stage Matrix'!$A:$A,0), MATCH(H$1,'Class-Stage Matrix'!$1:$1,0))
- const validationFormula =
- `=OR(` +
- `LEN(${colLetter}2)=0,` +
- `AND(` +
- `INDEX('Class-Stage Matrix'!$${matrixStageFirstColLetter}:$${matrixStageLastColLetter},` +
- `MATCH($${docClassColLetter}2,'Class-Stage Matrix'!${matrixClassCol},0),` +
- `MATCH(${colLetter}$1,'Class-Stage Matrix'!${matrixHeaderRowRange},0)` +
- `)<>\"\",` +
- `ISNUMBER(${colLetter}2)` +
- `)` +
- `)`
- worksheet.dataValidations.add(`${colLetter}2:${colLetter}1000`, {
- type: "custom",
- allowBlank: true,
- formulae: [validationFormula],
- showErrorMessage: true,
- errorTitle: "허용되지 않은 입력",
- error: "이 Stage는 선택한 Document Class에서 사용할 수 없거나 날짜 형식이 아닙니다.",
- })
-
- // ---- 조건부 서식 (유효하지 않은 Stage → 회색 배경) ----
- // TRUE이면 서식 적용: INDEX(...)="" -> 유효하지 않음
- const cfFormula =
- `IFERROR(` +
- `INDEX('Class-Stage Matrix'!$${matrixStageFirstColLetter}:$${matrixStageLastColLetter},` +
- `MATCH($${docClassColLetter}2,'Class-Stage Matrix'!${matrixClassCol},0),` +
- `MATCH(${colLetter}$1,'Class-Stage Matrix'!${matrixHeaderRowRange},0)` +
- `)=\"\",` +
- `TRUE` + // 매치 실패 등 오류 시에도 회색 처리
- `)`
- worksheet.addConditionalFormatting({
- ref: `${colLetter}2:${colLetter}1000`,
- rules: [
- {
- type: "expression",
- formulae: [cfFormula],
- style: {
- fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFEFEFEF" } }, // 연회색
- },
- },
- ],
- })
- }
-
- return workbook
-}
-
-// =============================================================================
-// Parse Documents with Stages - 통합 파싱
-// =============================================================================
-async function parseDocumentsWithStages(
- worksheet: ExcelJS.Worksheet,
- projectType: "ship" | "plant",
- contractId: number
-): Promise<{ validData: ParsedDocument[]; errors: string[] }> {
- const documents: ParsedDocument[] = []
- const errors: string[] = []
- const seenDocNumbers = new Set<string>()
-
- const res = await getDocumentClassOptionsByContract(contractId)
- if (!res.success) {
- errors.push("Document Class 정보를 불러올 수 없습니다")
- return { validData: [], errors }
- }
- const documentClasses = res.data.classes as Array<{ id: number; description: string }>
- const options = res.data.options as Array<{ documentClassId: number; optionValue: string }>
-
- // 클래스별 유효한 스테이지 맵
- const validStagesByClass = new Map<string, Set<string>>()
- for (const c of documentClasses) {
- const stages = options.filter((o) => o.documentClassId === c.id).map((o) => o.optionValue)
- validStagesByClass.set(c.description, new Set(stages))
- }
-
- // 헤더 파싱
- const headerRow = worksheet.getRow(1)
- const headers: string[] = []
- headerRow.eachCell((cell, colNumber) => {
- headers[colNumber - 1] = String(cell.value || "").trim()
- })
-
- const docNumberIdx = headers.findIndex((h) => h.includes("Document Number"))
- const docNameIdx = headers.findIndex((h) => h.includes("Document Name"))
- const docClassIdx = headers.findIndex((h) => h.includes("Document Class"))
- const vendorDocNoIdx = projectType === "plant" ? headers.findIndex((h) => h.includes("Project Doc No")) : -1
-
- if (docNumberIdx === -1 || docNameIdx === -1 || docClassIdx === -1) {
- errors.push("필수 헤더가 누락되었습니다")
- return { validData: [], errors }
- }
-
- const stageStartIdx = projectType === "plant" ? 4 : 3 // headers slice 기준(0-index)
- const stageHeaders = headers.slice(stageStartIdx)
-
- // 데이터 행 파싱
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber === 1) return
- const docNumber = String(row.getCell(docNumberIdx + 1).value || "").trim()
- const docName = String(row.getCell(docNameIdx + 1).value || "").trim()
- const docClass = String(row.getCell(docClassIdx + 1).value || "").trim()
- const vendorDocNo = vendorDocNoIdx >= 0 ? String(row.getCell(vendorDocNoIdx + 1).value || "").trim() : undefined
-
- if (!docNumber && !docName) return
- if (!docNumber) {
- errors.push(`행 ${rowNumber}: Document Number가 없습니다`)
- return
- }
- if (!docName) {
- errors.push(`행 ${rowNumber}: Document Name이 없습니다`)
- return
- }
- if (!docClass) {
- errors.push(`행 ${rowNumber}: Document Class가 없습니다`)
- return
- }
- if (projectType === "plant" && !vendorDocNo) {
- errors.push(`행 ${rowNumber}: Project Doc No.가 없습니다`)
- return
- }
- if (seenDocNumbers.has(docNumber)) {
- errors.push(`행 ${rowNumber}: 중복된 Document Number: ${docNumber}`)
- return
- }
- seenDocNumbers.add(docNumber)
-
- const validStages = validStagesByClass.get(docClass)
- if (!validStages) {
- errors.push(`행 ${rowNumber}: 유효하지 않은 Document Class: ${docClass}`)
- return
- }
-
-
-
- const stages: { stageName: string; planDate: string }[] = []
- stageHeaders.forEach((stageName, idx) => {
- if (validStages.has(stageName)) {
- const cell = row.getCell(stageStartIdx + idx + 1)
- let planDate = ""
- if (cell.value) {
- if (cell.value instanceof Date) {
- planDate = cell.value.toISOString().split("T")[0]
- } else {
- const dateStr = String(cell.value).trim()
- if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) planDate = dateStr
- }
- if (planDate) stages.push({ stageName, planDate })
- }
- }
- })
-
- documents.push({
- docNumber,
- title: docName,
- documentClass: docClass,
- vendorDocNumber: vendorDocNo,
- stages,
- })
- })
-
- return { validData: documents, errors }
-}
diff --git a/lib/vendor-document-list/plant/excel-import-stage copy.tsx b/lib/vendor-document-list/plant/excel-import-stage copy.tsx
deleted file mode 100644
index 068383af..00000000
--- a/lib/vendor-document-list/plant/excel-import-stage copy.tsx
+++ /dev/null
@@ -1,908 +0,0 @@
-
-
-"use client"
-
-import React from "react"
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Progress } from "@/components/ui/progress"
-import { Alert, AlertDescription } from "@/components/ui/alert"
-import { Upload, FileSpreadsheet, Loader2, Download, CheckCircle, AlertCircle } from "lucide-react"
-import { toast } from "sonner"
-import { useRouter } from "next/navigation"
-import ExcelJS from 'exceljs'
-import {
- getDocumentClassOptionsByContract,
- // These functions need to be implemented in document-stages-service
- uploadImportData,
-} from "./document-stages-service"
-
-// =============================================================================
-// Type Definitions
-// =============================================================================
-interface ExcelImportDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- contractId: number
- projectType: "ship" | "plant"
-}
-
-interface ImportResult {
- documents: any[]
- stages: any[]
- errors: string[]
- warnings: string[]
-}
-
-// =============================================================================
-// Main Component
-// =============================================================================
-export function ExcelImportDialog({
- open,
- onOpenChange,
- contractId,
- projectType
-}: ExcelImportDialogProps) {
- const [file, setFile] = React.useState<File | null>(null)
- const [isProcessing, setIsProcessing] = React.useState(false)
- const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false)
- const [importResult, setImportResult] = React.useState<ImportResult | null>(null)
- const [processStep, setProcessStep] = React.useState<string>("")
- const [progress, setProgress] = React.useState(0)
- const router = useRouter()
-
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- const selectedFile = e.target.files?.[0]
- if (selectedFile) {
- // 파일 유효성 검사
- if (!validateFileExtension(selectedFile)) {
- toast.error("Excel 파일(.xlsx, .xls)만 업로드 가능합니다.")
- return
- }
-
- if (!validateFileSize(selectedFile, 10)) {
- toast.error("파일 크기는 10MB 이하여야 합니다.")
- return
- }
-
- setFile(selectedFile)
- setImportResult(null)
- }
- }
-
- const handleDownloadTemplate = async () => {
- setIsDownloadingTemplate(true)
- try {
- const workbook = await createImportTemplate(projectType, contractId)
- const buffer = await workbook.xlsx.writeBuffer()
-
- const blob = new Blob([buffer], {
- type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
- })
-
- const url = window.URL.createObjectURL(blob)
- const link = document.createElement('a')
- link.href = url
- link.download = `문서_임포트_템플릿_${projectType}_${new Date().toISOString().split('T')[0]}.xlsx`
- link.click()
-
- window.URL.revokeObjectURL(url)
- toast.success("템플릿 파일이 다운로드되었습니다.")
- } catch (error) {
- toast.error("템플릿 다운로드 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : "알 수 없는 오류"))
- } finally {
- setIsDownloadingTemplate(false)
- }
- }
-
- const handleImport = async () => {
- if (!file) {
- toast.error("파일을 선택해주세요.")
- return
- }
-
- setIsProcessing(true)
- setProgress(0)
-
- try {
- setProcessStep("파일 읽는 중...")
- setProgress(20)
-
- const workbook = new ExcelJS.Workbook()
- const buffer = await file.arrayBuffer()
- await workbook.xlsx.load(buffer)
-
- setProcessStep("데이터 검증 중...")
- setProgress(40)
-
- // 워크시트 확인
- const documentsSheet = workbook.getWorksheet('Documents') || workbook.getWorksheet(1)
- const stagesSheet = workbook.getWorksheet('Stage Plan Dates') || workbook.getWorksheet(2)
-
- if (!documentsSheet) {
- throw new Error("Documents 시트를 찾을 수 없습니다.")
- }
-
- setProcessStep("문서 데이터 파싱 중...")
- setProgress(60)
-
- // 문서 데이터 파싱
- const documentData = await parseDocumentsSheet(documentsSheet, projectType)
-
- setProcessStep("스테이지 데이터 파싱 중...")
- setProgress(80)
-
- // 스테이지 데이터 파싱 (선택사항)
- let stageData: any[] = []
- if (stagesSheet) {
- stageData = await parseStagesSheet(stagesSheet)
- }
-
- setProcessStep("서버에 업로드 중...")
- setProgress(90)
-
- // 서버로 데이터 전송
- const result = await uploadImportData({
- contractId,
- documents: documentData.validData,
- stages: stageData,
- projectType
- })
-
- if (result.success) {
- setImportResult({
- documents: documentData.validData,
- stages: stageData,
- errors: documentData.errors,
- warnings: result.warnings || []
- })
- setProgress(100)
- toast.success(`${documentData.validData.length}개 문서가 성공적으로 임포트되었습니다.`)
- } else {
- throw new Error(result.error || "임포트에 실패했습니다.")
- }
-
- } catch (error) {
- toast.error(error instanceof Error ? error.message : "임포트 중 오류가 발생했습니다.")
- setImportResult({
- documents: [],
- stages: [],
- errors: [error instanceof Error ? error.message : "알 수 없는 오류"],
- warnings: []
- })
- } finally {
- setIsProcessing(false)
- setProcessStep("")
- setProgress(0)
- }
- }
-
- const handleClose = () => {
- setFile(null)
- setImportResult(null)
- setProgress(0)
- setProcessStep("")
- onOpenChange(false)
- }
-
- const handleConfirmImport = () => {
- router.refresh()
- handleClose()
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col">
- <DialogHeader className="flex-shrink-0">
- <DialogTitle>
- <FileSpreadsheet className="inline w-5 h-5 mr-2" />
- Excel 파일 임포트
- </DialogTitle>
- <DialogDescription>
- Excel 파일을 사용하여 문서를 일괄 등록합니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="flex-1 overflow-y-auto pr-2">
- <div className="grid gap-4 py-4">
- {/* 템플릿 다운로드 섹션 */}
- <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30">
- <h4 className="font-medium text-blue-800 dark:text-blue-200 mb-2">1. 템플릿 다운로드</h4>
- <p className="text-sm text-blue-700 dark:text-blue-300 mb-3">
- 올바른 형식과 드롭다운이 적용된 템플릿을 다운로드하세요.
- </p>
- <Button
- variant="outline"
- size="sm"
- onClick={handleDownloadTemplate}
- disabled={isDownloadingTemplate}
- >
- {isDownloadingTemplate ? (
- <Loader2 className="h-4 w-4 animate-spin mr-2" />
- ) : (
- <Download className="h-4 w-4 mr-2" />
- )}
- 템플릿 다운로드
- </Button>
- </div>
-
- {/* 파일 업로드 섹션 */}
- <div className="border rounded-lg p-4">
- <h4 className="font-medium mb-2">2. 파일 업로드</h4>
- <div className="grid gap-2">
- <Label htmlFor="excel-file">Excel 파일 선택</Label>
- <Input
- id="excel-file"
- type="file"
- accept=".xlsx,.xls"
- onChange={handleFileChange}
- disabled={isProcessing}
- className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
- />
- {file && (
- <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
- 선택된 파일: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
- </p>
- )}
- </div>
- </div>
-
- {/* 진행 상태 */}
- {isProcessing && (
- <div className="border rounded-lg p-4 bg-yellow-50/30 dark:bg-yellow-950/30">
- <div className="flex items-center gap-2 mb-2">
- <Loader2 className="h-4 w-4 animate-spin text-yellow-600" />
- <span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">처리 중...</span>
- </div>
- <p className="text-sm text-yellow-700 dark:text-yellow-300 mb-2">{processStep}</p>
- <Progress value={progress} className="h-2" />
- </div>
- )}
-
- {/* 임포트 결과 */}
- {importResult && (
- <ImportResultDisplay importResult={importResult} />
- )}
-
- {/* 파일 형식 가이드 */}
- <FileFormatGuide projectType={projectType} />
- </div>
- </div>
-
- <DialogFooter className="flex-shrink-0">
- <Button variant="outline" onClick={handleClose}>
- {importResult ? "닫기" : "취소"}
- </Button>
- {!importResult ? (
- <Button
- onClick={handleImport}
- disabled={!file || isProcessing}
- >
- {isProcessing ? (
- <Loader2 className="h-4 w-4 animate-spin mr-2" />
- ) : (
- <Upload className="h-4 w-4 mr-2" />
- )}
- {isProcessing ? "처리 중..." : "임포트 시작"}
- </Button>
- ) : importResult.documents.length > 0 ? (
- <Button onClick={handleConfirmImport}>
- 완료 및 새로고침
- </Button>
- ) : null}
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
-
-// =============================================================================
-// Sub Components
-// =============================================================================
-function ImportResultDisplay({ importResult }: { importResult: ImportResult }) {
- return (
- <div className="space-y-3">
- {importResult.documents.length > 0 && (
- <Alert>
- <CheckCircle className="h-4 w-4" />
- <AlertDescription>
- <strong>{importResult.documents.length}개</strong> 문서가 성공적으로 임포트되었습니다.
- {importResult.stages.length > 0 && (
- <> ({importResult.stages.length}개 스테이지 계획날짜 포함)</>
- )}
- </AlertDescription>
- </Alert>
- )}
-
- {importResult.warnings.length > 0 && (
- <Alert>
- <AlertCircle className="h-4 w-4" />
- <AlertDescription>
- <strong>경고:</strong>
- <ul className="mt-1 list-disc list-inside">
- {importResult.warnings.map((warning, index) => (
- <li key={index} className="text-sm">{warning}</li>
- ))}
- </ul>
- </AlertDescription>
- </Alert>
- )}
-
- {importResult.errors.length > 0 && (
- <Alert variant="destructive">
- <AlertCircle className="h-4 w-4" />
- <AlertDescription>
- <strong>오류:</strong>
- <ul className="mt-1 list-disc list-inside">
- {importResult.errors.map((error, index) => (
- <li key={index} className="text-sm">{error}</li>
- ))}
- </ul>
- </AlertDescription>
- </Alert>
- )}
- </div>
- )
-}
-
-function FileFormatGuide({ projectType }: { projectType: "ship" | "plant" }) {
- return (
- <div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
- <h4 className="font-medium text-gray-800 dark:text-gray-200 mb-2">파일 형식 가이드</h4>
- <div className="text-sm text-gray-700 dark:text-gray-300 space-y-1">
- <p><strong>Documents 시트:</strong></p>
- <ul className="ml-4 list-disc">
- <li>Document Number* (문서번호)</li>
- <li>Document Name* (문서명)</li>
- <li>Document Class* (문서클래스 - 드롭다운 선택)</li>
- {projectType === "plant" && (
- <li>Project Doc No. (벤더문서번호)</li>
- )}
- </ul>
- <p className="mt-2"><strong>Stage Plan Dates 시트 (선택사항):</strong></p>
- <ul className="ml-4 list-disc">
- <li>Document Number* (문서번호)</li>
- <li>Stage Name* (스테이지명 - 드롭다운 선택)</li>
- <li>Plan Date (계획날짜: YYYY-MM-DD)</li>
- </ul>
- <p className="mt-2 text-green-600 dark:text-green-400"><strong>스마트 기능:</strong></p>
- <ul className="ml-4 list-disc text-green-600 dark:text-green-400">
- <li>Document Class는 드롭다운으로 정확한 값만 선택 가능</li>
- <li>Stage Name도 드롭다운으로 오타 방지</li>
- <li>"사용 가이드" 시트에서 각 클래스별 사용 가능한 스테이지 확인 가능</li>
- </ul>
- <p className="mt-2 text-red-600 dark:text-red-400">* 필수 항목</p>
- </div>
- </div>
- )
-}
-
-// =============================================================================
-// Helper Functions
-// =============================================================================
-function validateFileExtension(file: File): boolean {
- const allowedExtensions = ['.xlsx', '.xls']
- const fileName = file.name.toLowerCase()
- return allowedExtensions.some(ext => fileName.endsWith(ext))
-}
-
-function validateFileSize(file: File, maxSizeMB: number): boolean {
- const maxSizeBytes = maxSizeMB * 1024 * 1024
- return file.size <= maxSizeBytes
-}
-
-// ExcelJS 컬럼 인덱스를 문자로 변환 (A, B, C, ... Z, AA, AB, ...)
-function getExcelColumnName(index: number): string {
- let result = ""
- while (index > 0) {
- index--
- result = String.fromCharCode(65 + (index % 26)) + result
- index = Math.floor(index / 26)
- }
- return result
-}
-
-// 헤더 행 스타일링 함수
-function styleHeaderRow(headerRow: ExcelJS.Row, bgColor: string = 'FF4472C4') {
- headerRow.eachCell((cell) => {
- cell.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: bgColor }
- }
- cell.font = {
- color: { argb: 'FFFFFFFF' },
- bold: true
- }
- cell.alignment = {
- horizontal: 'center',
- vertical: 'middle'
- }
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- }
-
- if (String(cell.value).includes('*')) {
- cell.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFE74C3C' }
- }
- }
- })
-}
-
-// 템플릿 생성 함수 - Stage Plan Dates 부분 수정
-async function createImportTemplate(projectType: "ship" | "plant", contractId: number) {
- const res = await getDocumentClassOptionsByContract(contractId)
- if (!res.success) throw new Error(res.error || "데이터 로딩 실패")
-
- const documentClasses = res.data.classes // [{id, code, description}]
- const options = res.data.options // [{documentClassId, optionValue, ...}]
-
- // 클래스별 옵션 맵
- const optionsByClassId = new Map<number, string[]>()
- for (const c of documentClasses) optionsByClassId.set(c.id, [])
- for (const o of options) {
- optionsByClassId.get(o.documentClassId)?.push(o.optionValue)
- }
-
- // 모든 스테이지 명 (유니크)
- const allStageNames = Array.from(new Set(options.map(o => o.optionValue)))
-
- const workbook = new ExcelJS.Workbook()
-
- // ================= Documents (첫 번째 시트) =================
- const documentsSheet = workbook.addWorksheet("Documents")
- const documentHeaders = [
- "Document Number*",
- "Document Name*",
- "Document Class*",
- ...(projectType === "plant" ? ["Project Doc No."] : []),
- "Notes",
- ]
- const documentHeaderRow = documentsSheet.addRow(documentHeaders)
- styleHeaderRow(documentHeaderRow)
-
- const sampleDocumentData =
- projectType === "ship"
- ? [
- "SH-2024-001",
- "기본 설계 도면",
- documentClasses[0] ? `${documentClasses[0].description}` : "",
- "참고사항",
- ]
- : [
- "PL-2024-001",
- "공정 설계 도면",
- documentClasses[0] ? `${documentClasses[0].description}` : "",
- "V-001",
- "참고사항",
- ]
-
- documentsSheet.addRow(sampleDocumentData)
-
- // Document Class 드롭다운
- const docClassColIndex = 3 // C
- const docClassCol = getExcelColumnName(docClassColIndex)
- documentsSheet.dataValidations.add(`${docClassCol}2:${docClassCol}1000`, {
- type: "list",
- allowBlank: false,
- formulae: [`ReferenceData!$A$2:$A${documentClasses.length + 1}`],
- })
-
- documentsSheet.columns = [
- { width: 15 },
- { width: 25 },
- { width: 28 },
- ...(projectType === "plant" ? [{ width: 18 }] : []),
- { width: 24 },
- ]
-
- // ================= Stage Plan Dates (두 번째 시트) - 수정됨 =================
- const stagesSheet = workbook.addWorksheet("Stage Plan Dates")
-
- // Document Class Helper 컬럼과 Valid Stage Helper 컬럼 추가
- const stageHeaderRow = stagesSheet.addRow([
- "Document Number*",
- "Document Class", // Helper 컬럼 - 자동으로 채워짐
- "Stage Name*",
- "Plan Date",
- "Valid Stages" // Helper 컬럼 - 유효한 스테이지 목록
- ])
- styleHeaderRow(stageHeaderRow, "FF27AE60")
-
- const firstClass = documentClasses[0]
- const firstClassOpts = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : []
-
- // 샘플 데이터
- const sampleStageData = [
- [
- projectType === "ship" ? "SH-2024-001" : "PL-2024-001",
- firstClass ? firstClass.description : "",
- firstClassOpts[0] ?? "",
- "2024-02-15",
- firstClassOpts.join(", ") // 유효한 스테이지 목록
- ],
- [
- projectType === "ship" ? "SH-2024-001" : "PL-2024-001",
- firstClass ? firstClass.description : "",
- firstClassOpts[1] ?? "",
- "2024-03-01",
- firstClassOpts.join(", ") // 유효한 스테이지 목록
- ],
- ]
-
- sampleStageData.forEach(row => {
- const r = stagesSheet.addRow(row)
- r.getCell(4).numFmt = "yyyy-mm-dd"
- })
-
- // B열(Document Class)에 VLOOKUP 수식 추가
- for (let i = 3; i <= 1000; i++) {
- const cell = stagesSheet.getCell(`B${i}`)
- cell.value = {
- formula: `IFERROR(VLOOKUP(A${i},Documents!A:C,3,FALSE),"")`,
- result: ""
- }
- }
-
-
- // E열(Valid Stages)에 수식 추가 - Document Class에 해당하는 스테이지 목록 표시
- // MATCH와 OFFSET을 사용한 동적 참조
-
-
- // Helper 컬럼 숨기기 옵션 (B, E열)
- stagesSheet.getColumn(2).hidden = false // Document Class는 보이도록 (확인용)
- stagesSheet.getColumn(5).hidden = false // Valid Stages도 보이도록 (가이드용)
-
- // Helper 컬럼 스타일링
- stagesSheet.getColumn(2).eachCell((cell, rowNumber) => {
- if (rowNumber > 1) {
- cell.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFF0F0F0' } // 연한 회색 배경
- }
- cell.protection = { locked: true } // 편집 방지
- }
- })
-
- stagesSheet.getColumn(5).eachCell((cell, rowNumber) => {
- if (rowNumber > 1) {
- cell.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFFFF0E0' } // 연한 주황색 배경
- }
- cell.font = { size: 9, italic: true }
- }
- })
-
- // Stage Name 드롭다운 - 전체 스테이지 목록 사용 (ExcelJS 제약으로 인해)
- // 하지만 조건부 서식으로 잘못된 선택 강조
- const allStagesCol = getExcelColumnName(documentClasses.length + 2)
- stagesSheet.dataValidations.add("C3:C1000", {
- type: "list",
- allowBlank: false,
- formulae: [`ReferenceData!${allStagesCol}$2:${allStagesCol}${allStageNames.length + 1}`],
- promptTitle: "Stage Name 선택",
- prompt: "Valid Stages 컬럼을 참고하여 올바른 Stage를 선택하세요",
- showErrorMessage: true,
- errorTitle: "Stage 선택 확인",
- error: "Valid Stages 컬럼에 있는 Stage만 유효합니다"
- })
-
- // 조건부 서식 추가 - 잘못된 Stage 선택시 빨간색 표시
- for (let i = 3; i <= 100; i++) {
- try {
- // COUNTIF를 사용하여 선택한 Stage가 Valid Stages에 포함되는지 확인
- const rule = {
- type: 'expression',
- formulae: [`ISERROR(SEARCH(C${i},E${i}))`],
- style: {
- fill: {
- type: 'pattern',
- pattern: 'solid',
- bgColor: { argb: 'FFFF0000' } // 빨간색 배경
- },
- font: {
- color: { argb: 'FFFFFFFF' } // 흰색 글자
- }
- }
- }
- stagesSheet.addConditionalFormatting({
- ref: `C${i}`,
- rules: [rule]
- })
- } catch (e) {
- console.warn(`Row ${i}: 조건부 서식 추가 실패`)
- }
- }
-
- stagesSheet.columns = [
- { width: 15 }, // Document Number
- { width: 20 }, // Document Class (Helper)
- { width: 30 }, // Stage Name
- { width: 12 }, // Plan Date
- { width: 50 } // Valid Stages (Helper)
- ]
-
- // ================= 사용 가이드 (세 번째 시트) - 수정됨 =================
- const guideSheet = workbook.addWorksheet("사용 가이드")
- const guideContent: (string[])[] = [
- ["문서 임포트 가이드"],
- [""],
- ["1. Documents 시트"],
- [" - Document Number*: 고유한 문서 번호를 입력하세요"],
- [" - Document Name*: 문서명을 입력하세요"],
- [" - Document Class*: 드롭다운에서 문서 클래스를 선택하세요"],
- [" - Project Doc No.: 벤더 문서 번호 (Plant 프로젝트만)"],
- [" - Notes: 참고사항"],
- [""],
- ["2. Stage Plan Dates 시트 (선택사항)"],
- [" - Document Number*: Documents 시트의 Document Number와 일치해야 합니다"],
- [" - Document Class: Document Number 입력시 자동으로 표시됩니다 (회색 배경)"],
- [" - Stage Name*: Valid Stages 컬럼을 참고하여 해당 클래스의 스테이지를 선택하세요"],
- [" - Plan Date: 계획 날짜 (YYYY-MM-DD 형식)"],
- [" - Valid Stages: 해당 Document Class에서 선택 가능한 스테이지 목록 (주황색 배경)"],
- [""],
- ["3. Stage Name 선택 방법"],
- [" ① Document Number를 먼저 입력합니다"],
- [" ② Document Class가 자동으로 표시됩니다"],
- [" ③ Valid Stages 컬럼에서 사용 가능한 스테이지를 확인합니다"],
- [" ④ Stage Name 드롭다운에서 Valid Stages에 있는 항목만 선택합니다"],
- [" ⑤ 잘못된 스테이지 선택시 셀이 빨간색으로 표시됩니다"],
- [""],
- ["4. 주의사항"],
- [" - * 표시는 필수 항목입니다"],
- [" - Document Number는 고유해야 합니다"],
- [" - Stage Name은 Valid Stages에 표시된 것만 유효합니다"],
- [" - 빨간색으로 표시된 Stage Name은 잘못된 선택입니다"],
- [" - 날짜는 YYYY-MM-DD 형식으로 입력하세요"],
- [""],
- ["5. Document Class별 사용 가능한 Stage Names"],
- [""],
- ]
-
- for (const c of documentClasses) {
- guideContent.push([`${c.code} - ${c.description}:`])
- ;(optionsByClassId.get(c.id) ?? []).forEach(v => guideContent.push([` • ${v}`]))
- guideContent.push([""])
- }
-
- guideContent.forEach((row, i) => {
- const r = guideSheet.addRow(row)
- if (i === 0) r.getCell(1).font = { bold: true, size: 14 }
- else if (row[0] && row[0].includes(":") && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true }
- })
- guideSheet.getColumn(1).width = 60
-
- // ================= ReferenceData (마지막 시트, hidden) =================
- const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" })
-
- let joinColStart = /* A=1 기준, 빈 안전한 위치 선택 */ documentClasses.length + 3;
-const keyCol = getExcelColumnName(joinColStart); // 예: X
-const joinedCol = getExcelColumnName(joinColStart+1); // 예: Y
-referenceSheet.getCell(`${keyCol}1`).value = "DocClass";
-referenceSheet.getCell(`${joinedCol}1`).value = "JoinedStages";
-
- // A열: DocumentClasses
- referenceSheet.getCell("A1").value = "DocumentClasses"
- documentClasses.forEach((dc, idx) => {
- referenceSheet.getCell(`${keyCol}${idx+2}`).value = dc.description;
- const stages = (optionsByClassId.get(dc.id) ?? []).join(", ");
- referenceSheet.getCell(`${joinedCol}${idx+2}`).value = stages;
- });
-
- for (let i = 3; i <= 1000; i++) {
- stagesSheet.getCell(`E${i}`).value = {
- formula: `IFERROR("유효한 스테이지: "&VLOOKUP(B${i},ReferenceData!$${keyCol}$2:$${joinedCol}$${documentClasses.length+1},2,FALSE),"Document Number를 먼저 입력하세요")`,
- result: ""
- };
- }
-
-
- // B열부터: 각 클래스의 Stage 옵션
- let currentCol = 2 // B
- for (const docClass of documentClasses) {
- const colLetter = getExcelColumnName(currentCol)
- referenceSheet.getCell(`${colLetter}1`).value = docClass.description
-
- const list = optionsByClassId.get(docClass.id) ?? []
- list.forEach((v, i) => {
- referenceSheet.getCell(`${colLetter}${i + 2}`).value = v
- })
-
- currentCol++
- }
-
- // 마지막 열: AllStageNames
- referenceSheet.getCell(`${allStagesCol}1`).value = "AllStageNames"
- allStageNames.forEach((v, i) => {
- referenceSheet.getCell(`${allStagesCol}${i + 2}`).value = v
- })
-
- return workbook
-}
-
-// =============================================================================
-// Type Definitions
-// =============================================================================
-interface ParsedDocument {
- docNumber: string
- title: string
- documentClass: string
- vendorDocNumber?: string
- notes?: string
- }
-
- interface ParsedStage {
- docNumber: string
- stageName: string
- planDate?: string
- }
-
- interface ParseResult {
- validData: ParsedDocument[]
- errors: string[]
- }
-
-
-// =============================================================================
-// 1. Parse Documents Sheet
-// =============================================================================
-export async function parseDocumentsSheet(
- worksheet: ExcelJS.Worksheet,
- projectType: "ship" | "plant"
- ): Promise<ParseResult> {
- const documents: ParsedDocument[] = []
- const errors: string[] = []
- const seenDocNumbers = new Set<string>()
-
- // 헤더 행 확인
- const headerRow = worksheet.getRow(1)
- const headers: string[] = []
- headerRow.eachCell((cell, colNumber) => {
- headers[colNumber - 1] = String(cell.value || "").trim()
- })
-
- // 헤더 인덱스 찾기
- const docNumberIdx = headers.findIndex(h => h.includes("Document Number"))
- const docNameIdx = headers.findIndex(h => h.includes("Document Name"))
- const docClassIdx = headers.findIndex(h => h.includes("Document Class"))
- const vendorDocNoIdx = projectType === "plant"
- ? headers.findIndex(h => h.includes("Project Doc No"))
- : -1
- const notesIdx = headers.findIndex(h => h.includes("Notes"))
-
- if (docNumberIdx === -1 || docNameIdx === -1 || docClassIdx === -1) {
- errors.push("필수 헤더가 누락되었습니다: Document Number, Document Name, Document Class")
- return { validData: [], errors }
- }
-
- // 데이터 행 파싱
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber === 1) return // 헤더 행 스킵
-
- const docNumber = String(row.getCell(docNumberIdx + 1).value || "").trim()
- const docName = String(row.getCell(docNameIdx + 1).value || "").trim()
- const docClass = String(row.getCell(docClassIdx + 1).value || "").trim()
- const vendorDocNo = vendorDocNoIdx >= 0
- ? String(row.getCell(vendorDocNoIdx + 1).value || "").trim()
- : undefined
- const notes = notesIdx >= 0
- ? String(row.getCell(notesIdx + 1).value || "").trim()
- : undefined
-
- // 빈 행 스킵
- if (!docNumber && !docName) return
-
- // 유효성 검사
- if (!docNumber) {
- errors.push(`행 ${rowNumber}: Document Number가 없습니다`)
- return
- }
- if (!docName) {
- errors.push(`행 ${rowNumber}: Document Name이 없습니다`)
- return
- }
- if (!docClass) {
- errors.push(`행 ${rowNumber}: Document Class가 없습니다`)
- return
- }
-
- // 중복 체크
- if (seenDocNumbers.has(docNumber)) {
- errors.push(`행 ${rowNumber}: 중복된 Document Number: ${docNumber}`)
- return
- }
- seenDocNumbers.add(docNumber)
-
- documents.push({
- docNumber,
- title: docName,
- documentClass: docClass,
- vendorDocNumber: vendorDocNo || undefined,
- notes: notes || undefined
- })
- })
-
- return { validData: documents, errors }
- }
-
-
-
-// parseStagesSheet 함수도 수정이 필요합니다
-export async function parseStagesSheet(worksheet: ExcelJS.Worksheet): Promise<ParsedStage[]> {
- const stages: ParsedStage[] = []
-
- // 헤더 행 확인
- const headerRow = worksheet.getRow(1)
- const headers: string[] = []
- headerRow.eachCell((cell, colNumber) => {
- headers[colNumber - 1] = String(cell.value || "").trim()
- })
-
- // 헤더 인덱스 찾기 (Helper 컬럼들 고려)
- const docNumberIdx = headers.findIndex(h => h.includes("Document Number"))
- // Stage Name 찾기 - "Stage Name*" 또는 "Stage Name"을 찾음
- const stageNameIdx = headers.findIndex(h => h.includes("Stage Name") && !h.includes("Valid"))
- const planDateIdx = headers.findIndex(h => h.includes("Plan Date"))
-
- if (docNumberIdx === -1 || stageNameIdx === -1) {
- console.error("Stage Plan Dates 시트에 필수 헤더가 없습니다")
- return []
- }
-
- // 데이터 행 파싱
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber === 1) return // 헤더 행 스킵
-
- const docNumber = String(row.getCell(docNumberIdx + 1).value || "").trim()
- const stageName = String(row.getCell(stageNameIdx + 1).value || "").trim()
- let planDate: string | undefined
-
- // Plan Date 파싱
- if (planDateIdx >= 0) {
- const planDateCell = row.getCell(planDateIdx + 1)
-
- if (planDateCell.value) {
- // Date 객체인 경우
- if (planDateCell.value instanceof Date) {
- planDate = planDateCell.value.toISOString().split('T')[0]
- }
- // 문자열인 경우
- else {
- const dateStr = String(planDateCell.value).trim()
- // YYYY-MM-DD 형식 검증
- if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
- planDate = dateStr
- }
- }
- }
- }
-
- // 빈 행 스킵
- if (!docNumber && !stageName) return
-
- // 유효성 검사
- if (!docNumber || !stageName) {
- console.warn(`행 ${rowNumber}: Document Number 또는 Stage Name이 누락됨`)
- return
- }
-
- stages.push({
- docNumber,
- stageName,
- planDate
- })
- })
-
- return stages
-} \ No newline at end of file
diff --git a/lib/vendor-document-list/plant/excel-import-stage.tsx b/lib/vendor-document-list/plant/excel-import-stage.tsx
index 24db6ea1..4f83d687 100644
--- a/lib/vendor-document-list/plant/excel-import-stage.tsx
+++ b/lib/vendor-document-list/plant/excel-import-stage.tsx
@@ -803,14 +803,14 @@ async function parseDocumentsWithStages(
errors.push("Document Class 정보를 불러올 수 없습니다")
return { validData: [], errors }
}
- const documentClasses = res.data.classes as Array<{ id: number; code: string; description: string }>
+ const documentClasses = res.data.classes as Array<{ id: number; value: string; description: string }>
const options = res.data.options as Array<{ documentClassId: number; optionValue: string }>
- // 클래스별 유효한 스테이지 맵 (code 기반)
+ // 클래스별 유효한 스테이지 맵 (value 기반)
const validStagesByClass = new Map<string, Set<string>>()
for (const c of documentClasses) {
const stages = options.filter((o) => o.documentClassId === c.id).map((o) => o.optionValue)
- validStagesByClass.set(c.code, new Set(stages))
+ validStagesByClass.set(c.value, new Set(stages))
}
// 헤더 파싱