summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list')
-rw-r--r--lib/vendor-document-list/plant/document-stage-actions.ts0
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx1433
-rw-r--r--lib/vendor-document-list/plant/document-stage-toolbar.tsx2
-rw-r--r--lib/vendor-document-list/plant/document-stages-columns.tsx316
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts458
-rw-r--r--lib/vendor-document-list/plant/document-stages-table.tsx234
-rw-r--r--lib/vendor-document-list/plant/excel-import-export.ts788
-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.tsx899
-rw-r--r--lib/vendor-document-list/plant/upload/columns.tsx13
-rw-r--r--lib/vendor-document-list/plant/upload/table.tsx25
12 files changed, 3802 insertions, 2173 deletions
diff --git a/lib/vendor-document-list/plant/document-stage-actions.ts b/lib/vendor-document-list/plant/document-stage-actions.ts
deleted file mode 100644
index e69de29b..00000000
--- a/lib/vendor-document-list/plant/document-stage-actions.ts
+++ /dev/null
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
index 14035562..779d31e1 100644
--- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx
+++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
@@ -32,7 +32,7 @@ import {
} from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { StageDocumentsView } from "@/db/schema"
-import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle} from "lucide-react"
+import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle, FileText, FolderOpen} from "lucide-react"
import { toast } from "sonner"
import {
getDocumentNumberTypes,
@@ -60,11 +60,11 @@ import {
DrawerTrigger,
} from "@/components/ui/drawer"
import { useRouter } from "next/navigation"
-import { cn, formatDate } from "@/lib/utils"
-import ExcelJS from 'exceljs'
-import { Progress } from "@/components/ui/progress"
import { Alert, AlertDescription } from "@/components/ui/alert"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Controller, useForm, useWatch } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
const getStatusVariant = (status: string) => {
switch (status) {
@@ -88,17 +88,24 @@ const getStatusText = (status: string) => {
}
-// =============================================================================
-// 1. Add Document Dialog
-// =============================================================================
+// Form validation schema
+const documentFormSchema = z.object({
+ documentClassId: z.string().min(1, "Document class is required"),
+ title: z.string().min(1, "Document title is required"),
+ shiFieldValues: z.record(z.string()).optional(),
+ cpyFieldValues: z.record(z.string()).optional(),
+ planDates: z.record(z.string()).optional(),
+})
+
+type DocumentFormValues = z.infer<typeof documentFormSchema>
+
interface AddDocumentDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
contractId: number
- projectType: "ship" | "plant"
+ projectType?: string
}
-
export function AddDocumentDialog({
open,
onOpenChange,
@@ -106,113 +113,115 @@ export function AddDocumentDialog({
projectType
}: AddDocumentDialogProps) {
const [isLoadingInitialData, setIsLoadingInitialData] = React.useState(false)
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [documentNumberTypes, setDocumentNumberTypes] = React.useState<any[]>([])
const [documentClasses, setDocumentClasses] = React.useState<any[]>([])
- const [selectedTypeConfigs, setSelectedTypeConfigs] = React.useState<any[]>([])
- const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({})
const [documentClassOptions, setDocumentClassOptions] = React.useState<any[]>([])
- // SHI와 CPY 타입 체크
+ // SHI related states
const [shiType, setShiType] = React.useState<any>(null)
+ const [shiTypeConfigs, setShiTypeConfigs] = React.useState<any[]>([])
+ const [shiComboBoxOptions, setShiComboBoxOptions] = React.useState<Record<number, any[]>>({})
+
+ // CPY related states
const [cpyType, setCpyType] = React.useState<any>(null)
- const [activeTab, setActiveTab] = React.useState<"SHI" | "CPY">("SHI")
- const [dataLoaded, setDataLoaded] = React.useState(false)
+ const [cpyTypeConfigs, setCpyTypeConfigs] = React.useState<any[]>([])
+ const [cpyComboBoxOptions, setCpyComboBoxOptions] = React.useState<Record<number, any[]>>({})
+
+ // Initialize react-hook-form
+ const form = useForm<DocumentFormValues>({
+ resolver: zodResolver(documentFormSchema),
+ defaultValues: {
+ documentClassId: '',
+ title: '',
+ shiFieldValues: {},
+ cpyFieldValues: {},
+ planDates: {},
+ },
+ })
- console.log(dataLoaded,"dataLoaded")
+ // Watch form values for reactive updates
+ const documentClassId = useWatch({
+ control: form.control,
+ name: 'documentClassId',
+ })
- const [formData, setFormData] = React.useState({
- documentNumberTypeId: "",
- documentClassId: "",
- title: "",
- fieldValues: {} as Record<string, string>,
- planDates: {} as Record<number, string>
+ const shiFieldValues = useWatch({
+ control: form.control,
+ name: 'shiFieldValues',
})
- // Load initial data
-// Dialog가 닫힐 때 상태 초기화를 확실히 하기
-React.useEffect(() => {
- if (!open) {
- // Dialog가 닫힐 때만 초기화
- resetForm()
- } else if (!dataLoaded) {
- // Dialog가 열리고 데이터가 로드되지 않았을 때만
- loadInitialData()
- }
-}, [open])
+ const cpyFieldValues = useWatch({
+ control: form.control,
+ name: 'cpyFieldValues',
+ })
+ // Load initial data when dialog opens
+ React.useEffect(() => {
+ if (open) {
+ loadInitialData()
+ } else {
+ // Reset form when dialog closes
+ form.reset()
+ setShiTypeConfigs([])
+ setCpyTypeConfigs([])
+ setShiComboBoxOptions({})
+ setCpyComboBoxOptions({})
+ setDocumentClassOptions([])
+ }
+ }, [open])
+
+ // Load document class options when class changes
+ React.useEffect(() => {
+ if (documentClassId) {
+ loadDocumentClassOptions(documentClassId)
+ }
+ }, [documentClassId])
const loadInitialData = async () => {
setIsLoadingInitialData(true)
- let foundShiType = null;
- let foundCpyType = null;
-
try {
const [typesResult, classesResult] = await Promise.all([
getDocumentNumberTypes(contractId),
getDocumentClasses(contractId)
])
- console.log(typesResult,"typesResult")
-
if (typesResult.success && typesResult.data) {
- setDocumentNumberTypes(typesResult.data)
-
- // 로컬 변수에 먼저 저장
- foundShiType = typesResult.data.find((type: any) =>
- type.name?.toUpperCase().trim() === "SHI"
+ const foundShiType = typesResult.data.find((type: any) =>
+ type.name?.toUpperCase().trim() === 'SHI'
)
- foundCpyType = typesResult.data.find((type: any) =>
- type.name?.toUpperCase().trim() === "CPY"
+ const foundCpyType = typesResult.data.find((type: any) =>
+ type.name?.toUpperCase().trim() === 'CPY'
)
setShiType(foundShiType || null)
setCpyType(foundCpyType || null)
-
- // 로컬 변수 사용
+
+ // Load configs for both types
if (foundShiType) {
- await handleTabChange("SHI", String(foundShiType.id))
- } else if (foundCpyType) {
- setActiveTab("CPY")
- await handleTabChange("CPY", String(foundCpyType.id))
+ await loadShiTypeConfigs(foundShiType.id)
+ }
+ if (foundCpyType) {
+ await loadCpyTypeConfigs(foundCpyType.id)
}
}
if (classesResult.success) {
setDocumentClasses(classesResult.data)
}
-
- setDataLoaded(true)
} catch (error) {
- console.error("Error loading data:", error)
- toast.error("Error loading data.")
+ console.error('Error loading initial data:', error)
+ toast.error('Error loading data.')
} finally {
- // 로컬 변수를 체크
- if (!foundShiType && !foundCpyType) {
- console.error("No types found after loading")
- }
setIsLoadingInitialData(false)
}
}
- // 탭 변경 처리
- const handleTabChange = async (tab: "SHI" | "CPY", typeId?: string) => {
- setActiveTab(tab)
-
- const documentNumberTypeId = typeId || (tab === "SHI" ? shiType?.id : cpyType?.id)
-
- if (documentNumberTypeId) {
- setFormData(prev => ({
- ...prev,
- documentNumberTypeId: String(documentNumberTypeId),
- fieldValues: {}
- }))
-
- const configsResult = await getDocumentNumberTypeConfigs(Number(documentNumberTypeId))
+ const loadShiTypeConfigs = async (typeId: number) => {
+ try {
+ const configsResult = await getDocumentNumberTypeConfigs(typeId)
if (configsResult.success) {
- setSelectedTypeConfigs(configsResult.data)
+ setShiTypeConfigs(configsResult.data)
- // Pre-load combobox options
+ // Pre-load combobox options for SHI
const comboBoxPromises = configsResult.data
.filter(config => config.codeGroup?.controlType === 'combobox')
.map(async (config) => {
@@ -230,62 +239,82 @@ React.useEffect(() => {
newComboBoxOptions[result.codeGroupId] = result.options
}
})
- setComboBoxOptions(newComboBoxOptions)
+ setShiComboBoxOptions(newComboBoxOptions)
}
- } else {
- setSelectedTypeConfigs([])
- setComboBoxOptions({})
+ } catch (error) {
+ console.error('Error loading SHI type configs:', error)
}
}
- // Handle field value change
- const handleFieldValueChange = (fieldKey: string, value: string) => {
- setFormData({
- ...formData,
- fieldValues: {
- ...formData.fieldValues,
- [fieldKey]: value
+ const loadCpyTypeConfigs = async (typeId: number) => {
+ try {
+ const configsResult = await getDocumentNumberTypeConfigs(typeId)
+ if (configsResult.success) {
+ setCpyTypeConfigs(configsResult.data)
+
+ // Pre-load combobox options for CPY
+ const comboBoxPromises = configsResult.data
+ .filter(config => config.codeGroup?.controlType === 'combobox')
+ .map(async (config) => {
+ const optionsResult = await getComboBoxOptions(config.codeGroupId!, contractId)
+ return {
+ codeGroupId: config.codeGroupId,
+ options: optionsResult.success ? optionsResult.data : []
+ }
+ })
+
+ const comboBoxResults = await Promise.all(comboBoxPromises)
+ const newComboBoxOptions: Record<number, any[]> = {}
+ comboBoxResults.forEach(result => {
+ if (result.codeGroupId) {
+ newComboBoxOptions[result.codeGroupId] = result.options
+ }
+ })
+ setCpyComboBoxOptions(newComboBoxOptions)
}
- })
+ } catch (error) {
+ console.error('Error loading CPY type configs:', error)
+ }
}
- // Handle document class change
- const handleDocumentClassChange = async (documentClassId: string) => {
- setFormData({
- ...formData,
- documentClassId,
- planDates: {}
- })
-
- if (documentClassId) {
- const optionsResult = await getDocumentClassOptions(Number(documentClassId))
+ const loadDocumentClassOptions = async (classId: string) => {
+ try {
+ const optionsResult = await getDocumentClassOptions(Number(classId))
if (optionsResult.success) {
setDocumentClassOptions(optionsResult.data)
+ // Reset plan dates for new class
+ form.setValue('planDates', {})
}
- } else {
- setDocumentClassOptions([])
+ } catch (error) {
+ console.error('Error loading class options:', error)
}
}
- // Handle plan date change
- const handlePlanDateChange = (optionId: number, date: string) => {
- setFormData({
- ...formData,
- planDates: {
- ...formData.planDates,
- [optionId]: date
+ // Generate document number preview for SHI
+ const generateShiPreview = () => {
+ if (shiTypeConfigs.length === 0) return ''
+
+ let preview = ''
+ shiTypeConfigs.forEach((config, index) => {
+ const fieldKey = `field_${config.sdq}`
+ const value = shiFieldValues?.[fieldKey] || '[value]'
+
+ if (index > 0 && config.delimiter) {
+ preview += config.delimiter
}
+ preview += value
})
+ return preview
}
- // Generate document number preview
- const generatePreviewDocNumber = () => {
- if (selectedTypeConfigs.length === 0) return ""
+ // Generate document number preview for CPY
+ const generateCpyPreview = () => {
+ if (cpyTypeConfigs.length === 0) return ''
- let preview = ""
- selectedTypeConfigs.forEach((config, index) => {
+ let preview = ''
+ cpyTypeConfigs.forEach((config, index) => {
const fieldKey = `field_${config.sdq}`
- const value = formData.fieldValues[fieldKey] || "[value]"
+ const value = cpyFieldValues?.[fieldKey] || '[value]'
if (index > 0 && config.delimiter) {
preview += config.delimiter
@@ -295,228 +324,155 @@ React.useEffect(() => {
return preview
}
- // Check if form is valid for submission
- const isFormValid = () => {
- if (!formData.documentNumberTypeId || !formData.documentClassId || !formData.title.trim()) {
- return false
- }
+ // Check if SHI fields are complete
+ const isShiComplete = () => {
+ if (!shiType || shiTypeConfigs.length === 0) return true // Skip if not configured
- const requiredConfigs = selectedTypeConfigs.filter(config => config.required)
+ const requiredConfigs = shiTypeConfigs.filter(config => config.required)
for (const config of requiredConfigs) {
const fieldKey = `field_${config.sdq}`
- const value = formData.fieldValues[fieldKey]
+ const value = shiFieldValues?.[fieldKey]
if (!value || !value.trim()) {
return false
}
}
- const docNumber = generatePreviewDocNumber()
- if (!docNumber || docNumber === "" || docNumber.includes("[value]")) {
- return false
+ const preview = generateShiPreview()
+ return preview && preview !== '' && !preview.includes('[value]')
+ }
+
+ // Check if CPY fields are complete
+ const isCpyComplete = () => {
+ if (!cpyType || cpyTypeConfigs.length === 0) return true // Skip if not configured
+
+ const requiredConfigs = cpyTypeConfigs.filter(config => config.required)
+ for (const config of requiredConfigs) {
+ const fieldKey = `field_${config.sdq}`
+ const value = cpyFieldValues?.[fieldKey]
+ if (!value || !value.trim()) {
+ return false
+ }
}
- return true
+ const preview = generateCpyPreview()
+ return preview && preview !== '' && !preview.includes('[value]')
}
- const handleSubmit = async () => {
- if (!isFormValid()) {
- toast.error("Please fill in all required fields.")
+ const onSubmit = async (data: DocumentFormValues) => {
+ // Validate that at least one document number is configured and complete
+ if (shiType && !isShiComplete()) {
+ toast.error('Please fill in all required SHI document number fields.')
return
}
-
- const generatedDocNumber = generatePreviewDocNumber()
- if (!generatedDocNumber) {
- toast.error("Cannot generate document number.")
+
+ if (cpyType && !isCpyComplete()) {
+ toast.error('Please fill in all required CPY project document number fields.')
return
}
- setIsSubmitting(true)
+ const shiDocNumber = shiType ? generateShiPreview() : ''
+ const cpyDocNumber = cpyType ? generateCpyPreview() : ''
+
try {
- // CPY 탭에서는 생성된 문서번호를 vendorDocNumber로 저장
const submitData = {
contractId,
- documentNumberTypeId: Number(formData.documentNumberTypeId),
- documentClassId: Number(formData.documentClassId),
- title: formData.title,
- docNumber: activeTab === "SHI" ? generatedDocNumber : "", // SHI는 docNumber로
- vendorDocNumber: activeTab === "CPY" ? generatedDocNumber : "", // CPY는 vendorDocNumber로
- fieldValues: formData.fieldValues,
- planDates: formData.planDates,
+ documentClassId: Number(data.documentClassId),
+ title: data.title,
+ docNumber: shiDocNumber,
+ vendorDocNumber: cpyDocNumber,
+ fieldValues: {
+ ...data.shiFieldValues,
+ ...data.cpyFieldValues
+ },
+ planDates: data.planDates || {},
}
const result = await createDocument(submitData)
if (result.success) {
- toast.success("Document added successfully.")
+ toast.success('Document added successfully.')
onOpenChange(false)
- resetForm()
+ form.reset()
} else {
- toast.error(result.error || "Error adding document.")
+ toast.error(result.error || 'Error adding document.')
}
} catch (error) {
- toast.error("Error adding document.")
- } finally {
- setIsSubmitting(false)
+ console.error('Error submitting document:', error)
+ toast.error('Error adding document.')
}
}
- const resetForm = () => {
- setFormData({
- documentNumberTypeId: "",
- documentClassId: "",
- title: "",
- fieldValues: {},
- planDates: {}
- })
- setSelectedTypeConfigs([])
- setComboBoxOptions({})
- setDocumentClassOptions([])
- setActiveTab("SHI")
- setDataLoaded(false)
- }
+ // Check if we have at least one type available
+ const hasAvailableTypes = shiType || cpyType
- // 공통 폼 컴포넌트
- const DocumentForm = () => (
- <div className="grid gap-4">
- {/* Dynamic Fields */}
- {selectedTypeConfigs.length > 0 && (
- <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30">
- <Label className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-3 block">
- Document Number Components
- </Label>
- <div className="grid gap-3">
- {selectedTypeConfigs.map((config) => (
- <div key={config.id} className="grid gap-2">
- <Label className="text-sm">
- {config.codeGroup?.description || config.description}
- {config.required && <span className="text-red-500 ml-1">*</span>}
- {config.remark && (
- <span className="text-xs text-gray-500 dark:text-gray-400 ml-2">({config.remark})</span>
- )}
- </Label>
+ // Render field component
+ const renderField = (
+ config: any,
+ fieldType: 'SHI' | 'CPY',
+ comboBoxOptions: Record<number, any[]>
+ ) => {
+ const fieldKey = `field_${config.sdq}`
+ const fieldName = fieldType === 'SHI'
+ ? `shiFieldValues.${fieldKey}`
+ : `cpyFieldValues.${fieldKey}`
- {config.codeGroup?.controlType === 'combobox' ? (
- <Select
- value={formData.fieldValues[`field_${config.sdq}`] || ""}
- onValueChange={(value) => handleFieldValueChange(`field_${config.sdq}`, value)}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select option" />
- </SelectTrigger>
- <SelectContent>
- {(comboBoxOptions[config.codeGroupId!] || []).map((option) => (
- <SelectItem key={option.id} value={option.code}>
- {option.code} - {option.description}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- ) : config.documentClass ? (
- <div className="p-2 bg-gray-100 dark:bg-gray-800 rounded text-sm">
- {config.documentClass.code} - {config.documentClass.description}
- </div>
- ) : (
- <Input
- value={formData.fieldValues[`field_${config.sdq}`] || ""}
- onChange={(e) => handleFieldValueChange(`field_${config.sdq}`, e.target.value)}
- placeholder="Enter value"
- />
- )}
- </div>
- ))}
- </div>
-
- {/* Document Number Preview */}
- <div className="mt-3 p-2 bg-white dark:bg-gray-900 border rounded">
- <Label className="text-xs text-gray-600 dark:text-gray-400">
- {activeTab === "SHI" ? "Document Number" : "Project Document Number"} Preview:
- </Label>
- <div className="font-mono text-sm font-medium text-blue-600 dark:text-blue-400">
- {generatePreviewDocNumber()}
- </div>
- </div>
- </div>
- )}
-
- {/* Document Class Selection */}
- <div className="grid gap-2">
- <Label htmlFor="documentClassId">
- Document Class <span className="text-red-500">*</span>
+ return (
+ <div key={config.id} className="grid gap-2">
+ <Label className="text-sm">
+ {config.codeGroup?.description || config.description}
+ {config.required && <span className="text-red-500 ml-1">*</span>}
+ {config.remark && (
+ <span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
+ ({config.remark})
+ </span>
+ )}
</Label>
- <Select
- value={formData.documentClassId}
- onValueChange={handleDocumentClassChange}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select document class" />
- </SelectTrigger>
- <SelectContent>
- {documentClasses.map((cls) => (
- <SelectItem key={cls.id} value={String(cls.id)}>
- {cls.value}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- {formData.documentClassId && (
- <p className="text-xs text-gray-600 dark:text-gray-400">
- Options from the selected class will be automatically created as stages.
- </p>
- )}
- </div>
- {/* Document Class Options with Plan Dates */}
- {documentClassOptions.length > 0 && (
- <div className="border rounded-lg p-4 bg-green-50/30 dark:bg-green-950/30">
- <Label className="text-sm font-medium text-green-800 dark:text-green-200 mb-3 block">
- Document Class Stages with Plan Dates
- </Label>
- <div className="grid gap-3">
- {documentClassOptions.map((option) => (
- <div key={option.id} className="grid grid-cols-2 gap-3 items-center">
- <div>
- <Label className="text-sm font-medium">
- {option.optionValue}
- </Label>
- {option.optionCode && (
- <p className="text-xs text-gray-500 dark:text-gray-400">Code: {option.optionCode}</p>
- )}
- </div>
- <div className="grid gap-1">
- <Label className="text-xs text-gray-600 dark:text-gray-400">Plan Date</Label>
- <Input
- type="date"
- value={formData.planDates[option.id] || ""}
- onChange={(e) => handlePlanDateChange(option.id, e.target.value)}
- className="text-sm"
- />
+ <Controller
+ name={fieldName as any}
+ control={form.control}
+ rules={{ required: config.required }}
+ render={({ field }) => {
+ if (config.codeGroup?.controlType === 'combobox') {
+ return (
+ <Select value={field.value || ''} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="Select option" />
+ </SelectTrigger>
+ <SelectContent>
+ {(comboBoxOptions[config.codeGroupId!] || []).map((option) => (
+ <SelectItem key={option.id} value={option.code}>
+ {option.code} - {option.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )
+ } 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}
</div>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* Document Title */}
- <div className="grid gap-2">
- <Label htmlFor="title">
- Document Title <span className="text-red-500">*</span>
- </Label>
- <Input
- id="title"
- value={formData.title}
- onChange={(e) => setFormData({ ...formData, title: e.target.value })}
- placeholder="Enter document title"
+ )
+ } else {
+ return (
+ <Input
+ {...field}
+ value={field.value || ''}
+ placeholder="Enter value"
+ />
+ )
+ }
+ }}
/>
</div>
- </div>
- )
+ )
+ }
- // 로딩 중이거나 데이터 체크 중일 때 표시
if (isLoadingInitialData) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
+ <DialogContent className="sm:max-w-[800px] h-[80vh] flex flex-col">
<div className="flex items-center justify-center py-8 flex-1">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
@@ -525,98 +481,239 @@ React.useEffect(() => {
)
}
- return (
-<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col overflow-hidden">
- <DialogHeader className="flex-shrink-0">
- <DialogTitle>Add New Document</DialogTitle>
- <DialogDescription>
- Enter the basic information for the new document.
- </DialogDescription>
- </DialogHeader>
-
- {!shiType && !cpyType ? (
- <div className="flex-1 flex items-center justify-center">
- <Alert className="max-w-md">
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription>
- Required Document Number Type (SHI, CPY) is not configured. Please configure it first in the Number Types management.
- </AlertDescription>
- </Alert>
- </div>
- ) : (
- <>
- <Tabs
- value={activeTab}
- onValueChange={(v) => handleTabChange(v as "SHI" | "CPY")}
- className="flex-1 min-h-0 flex flex-col"
- >
- {/* 고정 영역 */}
- <TabsList className="grid w-full grid-cols-2 flex-shrink-0">
- <TabsTrigger value="SHI" disabled={!shiType}>
- SHI (Document No.)
- {!shiType && <AlertTriangle className="ml-2 h-3 w-3" />}
- </TabsTrigger>
- <TabsTrigger value="CPY" disabled={!cpyType}>
- CPY (Project Document No.)
- {!cpyType && <AlertTriangle className="ml-2 h-3 w-3" />}
- </TabsTrigger>
- </TabsList>
-
- {/* 스크롤 영역 */}
- <div className="flex-1 min-h-0 mt-4 overflow-y-auto pr-2">
- <TabsContent
- value="SHI"
- className="data-[state=inactive]:hidden"
- >
- {shiType ? (
- <DocumentForm />
- ) : (
- <Alert>
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription>
- SHI Document Number Type is not configured.
- </AlertDescription>
- </Alert>
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col overflow-hidden">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>Add New Document</DialogTitle>
+ <DialogDescription>
+ Enter the document information and generate document numbers.
+ </DialogDescription>
+ </DialogHeader>
+
+ {!hasAvailableTypes ? (
+ <div className="flex-1 flex items-center justify-center">
+ <Alert className="max-w-md">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ Required Document Number Type (SHI, CPY) is not configured.
+ Please configure it first in the Number Types management.
+ </AlertDescription>
+ </Alert>
+ </div>
+ ) : (
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col min-h-0">
+ <div className="flex-1 overflow-y-auto pr-2 space-y-4">
+
+ {/* SHI Document Number Card */}
+ {shiType && (
+ <Card className="border-blue-200 dark:border-blue-800">
+ <CardHeader className="pb-4">
+ <CardTitle className="flex items-center gap-2 text-lg">
+ <FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
+ SHI Document Number
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ {shiTypeConfigs.length > 0 ? (
+ <>
+ <div className="grid gap-3">
+ {shiTypeConfigs.map((config) =>
+ renderField(config, 'SHI', shiComboBoxOptions)
+ )}
+ </div>
+
+ {/* SHI Preview */}
+ <div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded">
+ <Label className="text-xs text-blue-700 dark:text-blue-300">
+ Document Number Preview:
+ </Label>
+ <div className="font-mono text-sm font-medium text-blue-800 dark:text-blue-200 mt-1">
+ {generateShiPreview()}
+ </div>
+ </div>
+ </>
+ ) : (
+ <div className="text-sm text-gray-500">
+ Loading SHI configuration...
+ </div>
+ )}
+ </CardContent>
+ </Card>
)}
- </TabsContent>
- <TabsContent
- value="CPY"
- className="data-[state=inactive]:hidden"
- >
- {cpyType ? (
- <DocumentForm />
- ) : (
- <Alert>
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription>
- CPY Document Number Type is not configured.
- </AlertDescription>
- </Alert>
+ {/* CPY Project Document Number Card */}
+ {cpyType && (
+ <Card className="border-green-200 dark:border-green-800">
+ <CardHeader className="pb-4">
+ <CardTitle className="flex items-center gap-2 text-lg">
+ <FolderOpen className="h-5 w-5 text-green-600 dark:text-green-400" />
+ CPY Project Document Number
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ {cpyTypeConfigs.length > 0 ? (
+ <>
+ <div className="grid gap-3">
+ {cpyTypeConfigs.map((config) =>
+ renderField(config, 'CPY', cpyComboBoxOptions)
+ )}
+ </div>
+
+ {/* CPY Preview */}
+ <div className="mt-3 p-3 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 rounded">
+ <Label className="text-xs text-green-700 dark:text-green-300">
+ Project Document Number Preview:
+ </Label>
+ <div className="font-mono text-sm font-medium text-green-800 dark:text-green-200 mt-1">
+ {generateCpyPreview()}
+ </div>
+ </div>
+ </>
+ ) : (
+ <div className="text-sm text-gray-500">
+ Loading CPY configuration...
+ </div>
+ )}
+ </CardContent>
+ </Card>
)}
- </TabsContent>
- </div>
- </Tabs>
- <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4">
- <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
- Cancel
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={isSubmitting || !isFormValid() || (!shiType && !cpyType)}
- >
- {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
- Add Document
- </Button>
- </DialogFooter>
- </>
- )}
- </DialogContent>
-</Dialog>
+ {/* Document Class Selection */}
+ <div className="space-y-2">
+ <Label htmlFor="documentClassId">
+ Document Class <span className="text-red-500">*</span>
+ </Label>
+ <Controller
+ name="documentClassId"
+ control={form.control}
+ render={({ field }) => (
+ <Select value={field.value} onValueChange={field.onChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="Select document class" />
+ </SelectTrigger>
+ <SelectContent>
+ {documentClasses.map((cls) => (
+ <SelectItem key={cls.id} value={String(cls.id)}>
+ {cls.value}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )}
+ />
+ {form.formState.errors.documentClassId && (
+ <p className="text-xs text-red-500">
+ {form.formState.errors.documentClassId.message}
+ </p>
+ )}
+ {documentClassId && (
+ <p className="text-xs text-gray-600 dark:text-gray-400">
+ Options from the selected class will be automatically created as stages.
+ </p>
+ )}
+ </div>
+
+ {/* Document Class Options with Plan Dates */}
+ {documentClassOptions.length > 0 && (
+ <Card>
+ <CardHeader className="pb-4">
+ <CardTitle className="text-base">Document Stages with Plan Dates</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid gap-3">
+ {documentClassOptions.map((option) => (
+ <div key={option.id} className="grid grid-cols-2 gap-3 items-center">
+ <div>
+ <Label className="text-sm font-medium">
+ {option.optionValue}
+ </Label>
+ {option.optionCode && (
+ <p className="text-xs text-gray-500 dark:text-gray-400">
+ Code: {option.optionCode}
+ </p>
+ )}
+ </div>
+ <div className="grid gap-1">
+ <Label className="text-xs text-gray-600 dark:text-gray-400">
+ Plan Date
+ </Label>
+ <Controller
+ name={`planDates.${option.id}`}
+ control={form.control}
+ render={({ field }) => (
+ <Input
+ type="date"
+ {...field}
+ value={field.value || ''}
+ className="text-sm"
+ />
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* Document Title */}
+ <div className="space-y-2">
+ <Label htmlFor="title">
+ Document Title <span className="text-red-500">*</span>
+ </Label>
+ <Controller
+ name="title"
+ control={form.control}
+ render={({ field }) => (
+ <Input
+ {...field}
+ id="title"
+ placeholder="Enter document title"
+ />
+ )}
+ />
+ {form.formState.errors.title && (
+ <p className="text-xs text-red-500">
+ {form.formState.errors.title.message}
+ </p>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={form.formState.isSubmitting}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={
+ form.formState.isSubmitting ||
+ !hasAvailableTypes ||
+ (shiType && !isShiComplete()) ||
+ (cpyType && !isCpyComplete())
+ }
+ >
+ {form.formState.isSubmitting ? (
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ ) : null}
+ Add Document
+ </Button>
+ </DialogFooter>
+ </form>
+ )}
+ </DialogContent>
+ </Dialog>
)
}
+
+
// =============================================================================
// Edit Document Dialog (with improved stage plan date editing)
// =============================================================================
@@ -690,7 +787,7 @@ export function EditDocumentDialog({
setIsLoading(true)
try {
const result = await updateDocument({
- documentId: document.id,
+ documentId: document.documentId,
title: formData.title,
vendorDocNumber: formData.vendorDocNumber,
stagePlanDates: formData.stagePlanDates,
@@ -1019,361 +1116,6 @@ export function EditStageDialog({
</Dialog>
)
}
-// =============================================================================
-// 4. Excel Import Dialog
-// =============================================================================
-interface ExcelImportDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- contractId: number
- projectType: "ship" | "plant"
-}
-
-interface ImportResult {
- documents: any[]
- stages: any[]
- errors: string[]
- warnings: string[]
-}
-
-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 validateFileExtension = (file: File): boolean => {
- const allowedExtensions = ['.xlsx', '.xls']
- const fileName = file.name.toLowerCase()
- return allowedExtensions.some(ext => fileName.endsWith(ext))
- }
-
- const validateFileSize = (file: File, maxSizeMB: number): boolean => {
- const maxSizeBytes = maxSizeMB * 1024 * 1024
- return file.size <= maxSizeBytes
- }
-
- // 템플릿 다운로드
- 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 && (
- <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>
- )}
-
- {/* 파일 형식 가이드 */}
- <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>
- </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>
- )
-}
// =============================================================================
// 5. Delete Documents Confirmation Dialog
@@ -1399,6 +1141,7 @@ export function DeleteDocumentsDialog({
function onDelete() {
startDeleteTransition(async () => {
+
const { error } = await deleteDocuments({
ids: documents.map((document) => document.documentId),
})
@@ -1500,223 +1243,3 @@ export function DeleteDocumentsDialog({
)
}
-// =============================================================================
-// Helper Functions for Excel Import
-// =============================================================================
-
-// 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' }
- }
- }
- })
-}
-
-// 템플릿 생성 함수
-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();
-
- // ================= ReferenceData (hidden) =================
- const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" });
-
- // A열: DocumentClasses
- referenceSheet.getCell("A1").value = "DocumentClasses";
- documentClasses.forEach((docClass, i) => {
- referenceSheet.getCell(`A${i + 2}`).value = `${docClass.description}`;
- });
-
- // 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
- const allStagesCol = getExcelColumnName(currentCol);
- referenceSheet.getCell(`${allStagesCol}1`).value = "AllStageNames";
- allStageNames.forEach((v, i) => {
- referenceSheet.getCell(`${allStagesCol}${i + 2}`).value = v;
- });
-
- // ================= 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");
- const stageHeaderRow = stagesSheet.addRow(["Document Number*", "Stage Name*", "Plan Date"]);
- styleHeaderRow(stageHeaderRow, "FF27AE60");
-
- const firstClass = documentClasses[0];
- const firstClassOpts = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : [];
-
- const sampleStageData = [
- [projectType === "ship" ? "SH-2024-001" : "PL-2024-001", firstClassOpts[0] ?? "", "2024-02-15"],
- [projectType === "ship" ? "SH-2024-001" : "PL-2024-001", firstClassOpts[1] ?? "", "2024-03-01"],
- ];
-
- sampleStageData.forEach(row => {
- const r = stagesSheet.addRow(row);
- r.getCell(3).numFmt = "yyyy-mm-dd";
- });
-
- // Stage Name 드롭다운 (전체)
- stagesSheet.dataValidations.add("B2:B1000", {
- type: "list",
- allowBlank: false,
- formulae: [`ReferenceData!$${allStagesCol}$2:$${allStagesCol}$${allStageNames.length + 1}`],
- promptTitle: "Stage Name 선택",
- prompt: "Document의 Document Class에 해당하는 Stage Name을 선택하세요.",
- });
-
- stagesSheet.columns = [{ width: 15 }, { width: 30 }, { width: 12 }];
-
- // ================= 사용 가이드 =================
- const guideSheet = workbook.addWorksheet("사용 가이드");
- const guideContent: (string[])[] = [
- ["문서 임포트 가이드"],
- [""],
- ["1. Documents 시트"],
- [" - Document Number*: 고유한 문서 번호를 입력하세요"],
- [" - Document Name*: 문서명을 입력하세요"],
- [" - Document Class*: 드롭다운에서 문서 클래스를 선택하세요"],
- [" - Project Doc No.: 벤더 문서 번호"],
- [" - Notes: 참고사항"],
- [""],
- ["2. Stage Plan Dates 시트 (선택사항)"],
- [" - Document Number*: Documents 시트의 Document Number와 일치해야 합니다"],
- [" - Stage Name*: 드롭다운에서 해당 문서 클래스에 맞는 스테이지명을 선택하세요"],
- [" - Plan Date: 계획 날짜 (YYYY-MM-DD 형식)"],
- [""],
- ["3. 주의사항"],
- [" - * 표시는 필수 항목입니다"],
- [" - Document Number는 고유해야 합니다"],
- [" - Stage Name은 해당 Document의 Document Class에 속한 것만 유효합니다"],
- [" - 날짜는 YYYY-MM-DD 형식으로 입력하세요"],
- [""],
- ["4. 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;
-
- return workbook;
-} \ No newline at end of file
diff --git a/lib/vendor-document-list/plant/document-stage-toolbar.tsx b/lib/vendor-document-list/plant/document-stage-toolbar.tsx
index ccb9e15c..f676e1fc 100644
--- a/lib/vendor-document-list/plant/document-stage-toolbar.tsx
+++ b/lib/vendor-document-list/plant/document-stage-toolbar.tsx
@@ -12,13 +12,13 @@ import { Button } from "@/components/ui/button"
import {
DeleteDocumentsDialog,
AddDocumentDialog,
- ExcelImportDialog
} from "./document-stage-dialogs"
import { sendDocumentsToSHI } from "./document-stages-service"
import { useDocumentPolling } from "@/hooks/use-document-polling"
import { cn } from "@/lib/utils"
import { MultiUploadDialog } from "./upload/components/multi-upload-dialog"
import { useRouter } from "next/navigation"
+import { ExcelImportDialog } from "./excel-import-stage"
// 서버 액션 import (필요한 경우)
// import { importDocumentsExcel } from "./document-stages-service"
diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx
index 0b85c3f8..d71ecc0f 100644
--- a/lib/vendor-document-list/plant/document-stages-columns.tsx
+++ b/lib/vendor-document-list/plant/document-stages-columns.tsx
@@ -347,166 +347,166 @@ export function getDocumentStagesColumns({
},
},
- {
- accessorKey: "buyerSystemStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="SHI Status" />
- ),
- cell: ({ row }) => {
- const doc = row.original
- const getBuyerStatusBadge = () => {
- if (!doc.buyerSystemStatus) {
- return <Badge variant="outline">Not Recieved</Badge>
- }
+ // {
+ // accessorKey: "buyerSystemStatus",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="SHI Status" />
+ // ),
+ // cell: ({ row }) => {
+ // const doc = row.original
+ // const getBuyerStatusBadge = () => {
+ // if (!doc.buyerSystemStatus) {
+ // return <Badge variant="outline">Not Recieved</Badge>
+ // }
- switch (doc.buyerSystemStatus) {
- case '승인(DC)':
- return <Badge variant="success">Approved</Badge>
- case '검토중':
- return <Badge variant="default">검토중</Badge>
- case '반려':
- return <Badge variant="destructive">반려</Badge>
- default:
- return <Badge variant="secondary">{doc.buyerSystemStatus}</Badge>
- }
- }
+ // switch (doc.buyerSystemStatus) {
+ // case '승인(DC)':
+ // return <Badge variant="success">Approved</Badge>
+ // case '검토중':
+ // return <Badge variant="default">검토중</Badge>
+ // case '반려':
+ // return <Badge variant="destructive">반려</Badge>
+ // default:
+ // return <Badge variant="secondary">{doc.buyerSystemStatus}</Badge>
+ // }
+ // }
- return (
- <div className="flex flex-col gap-1">
- {getBuyerStatusBadge()}
- {doc.buyerSystemComment && (
- <Tooltip>
- <TooltipTrigger>
- <MessageSquare className="h-3 w-3 text-muted-foreground" />
- </TooltipTrigger>
- <TooltipContent>
- <p className="max-w-xs">{doc.buyerSystemComment}</p>
- </TooltipContent>
- </Tooltip>
- )}
- </div>
- )
- },
- size: 120,
- },
- {
- accessorKey: "currentStageName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Current Stage" />
- ),
- cell: ({ row }) => {
- const doc = row.original
- // if (!doc.currentStageName) {
- // return (
- // <Button
- // size="sm"
- // variant="outline"
- // onClick={(e) => {
- // e.stopPropagation()
- // setRowAction({ row, type: "add_stage" })
- // }}
- // className="h-6 text-xs"
- // >
- // <Plus className="w-3 h-3 mr-1" />
- // Add stage
- // </Button>
- // )
- // }
-
- return (
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium truncate" title={doc.currentStageName}>
- {doc.currentStageName}
- </span>
- <Badge
- variant={getStatusColor(doc.currentStageStatus || '', doc.isOverdue || false)}
- className="text-xs px-1.5 py-0"
- >
- {getStatusText(doc.currentStageStatus || '')}
- </Badge>
- {doc.currentStageAssigneeName && (
- <span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
- <User className="w-3 h-3" />
- {doc.currentStageAssigneeName}
- </span>
- )}
- </div>
- )
- },
- size: 180,
- enableResizing: true,
- meta: {
- excelHeader: "Current Stage"
- },
- },
-
- // 계획 일정 (한 줄)
- {
- accessorKey: "currentStagePlanDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Plan Date" />
- ),
- cell: ({ row }) => {
- const doc = row.original
- if (!doc.currentStagePlanDate) return <span className="text-gray-400 dark:text-gray-500">-</span>
-
- return (
- <div className="flex items-center gap-2">
- <span className="text-sm">{formatDate(doc.currentStagePlanDate)}</span>
- <DueDateInfo
- daysUntilDue={doc.daysUntilDue}
- isOverdue={doc.isOverdue || false}
- />
- </div>
- )
- },
- size: 150,
- enableResizing: true,
- meta: {
- excelHeader: "Plan Date"
- },
- },
-
- // 우선순위 + 진행률 (콤팩트)
- {
- accessorKey: "progressPercentage",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Priority/Progress" />
- ),
- cell: ({ row }) => {
- const doc = row.original
- const progress = doc.progressPercentage || 0
- const completed = doc.completedStages || 0
- const total = doc.totalStages || 0
-
- return (
- <div className="flex items-center gap-2">
- {doc.currentStagePriority && (
- <Badge
- variant={getPriorityColor(doc.currentStagePriority)}
- className="text-xs px-1.5 py-0"
- >
- {getPriorityText(doc.currentStagePriority)}
- </Badge>
- )}
- <div className="flex items-center gap-1">
- <Progress value={progress} className="w-12 h-1.5" />
- <span className="text-xs text-gray-600 dark:text-gray-400 min-w-[2rem]">
- {progress}%
- </span>
- </div>
- <span className="text-xs text-gray-500 dark:text-gray-400">
- ({completed}/{total})
- </span>
- </div>
- )
- },
- size: 140,
- enableResizing: true,
- meta: {
- excelHeader: "Progress"
- },
- },
+ // return (
+ // <div className="flex flex-col gap-1">
+ // {getBuyerStatusBadge()}
+ // {doc.buyerSystemComment && (
+ // <Tooltip>
+ // <TooltipTrigger>
+ // <MessageSquare className="h-3 w-3 text-muted-foreground" />
+ // </TooltipTrigger>
+ // <TooltipContent>
+ // <p className="max-w-xs">{doc.buyerSystemComment}</p>
+ // </TooltipContent>
+ // </Tooltip>
+ // )}
+ // </div>
+ // )
+ // },
+ // size: 120,
+ // },
+ // {
+ // accessorKey: "currentStageName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="Current Stage" />
+ // ),
+ // cell: ({ row }) => {
+ // const doc = row.original
+ // // if (!doc.currentStageName) {
+ // // return (
+ // // <Button
+ // // size="sm"
+ // // variant="outline"
+ // // onClick={(e) => {
+ // // e.stopPropagation()
+ // // setRowAction({ row, type: "add_stage" })
+ // // }}
+ // // className="h-6 text-xs"
+ // // >
+ // // <Plus className="w-3 h-3 mr-1" />
+ // // Add stage
+ // // </Button>
+ // // )
+ // // }
+
+ // return (
+ // <div className="flex items-center gap-2">
+ // <span className="text-sm font-medium truncate" title={doc.currentStageName}>
+ // {doc.currentStageName}
+ // </span>
+ // <Badge
+ // variant={getStatusColor(doc.currentStageStatus || '', doc.isOverdue || false)}
+ // className="text-xs px-1.5 py-0"
+ // >
+ // {getStatusText(doc.currentStageStatus || '')}
+ // </Badge>
+ // {doc.currentStageAssigneeName && (
+ // <span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
+ // <User className="w-3 h-3" />
+ // {doc.currentStageAssigneeName}
+ // </span>
+ // )}
+ // </div>
+ // )
+ // },
+ // size: 180,
+ // enableResizing: true,
+ // meta: {
+ // excelHeader: "Current Stage"
+ // },
+ // },
+
+ // // 계획 일정 (한 줄)
+ // {
+ // accessorKey: "currentStagePlanDate",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="Plan Date" />
+ // ),
+ // cell: ({ row }) => {
+ // const doc = row.original
+ // if (!doc.currentStagePlanDate) return <span className="text-gray-400 dark:text-gray-500">-</span>
+
+ // return (
+ // <div className="flex items-center gap-2">
+ // <span className="text-sm">{formatDate(doc.currentStagePlanDate)}</span>
+ // <DueDateInfo
+ // daysUntilDue={doc.daysUntilDue}
+ // isOverdue={doc.isOverdue || false}
+ // />
+ // </div>
+ // )
+ // },
+ // size: 150,
+ // enableResizing: true,
+ // meta: {
+ // excelHeader: "Plan Date"
+ // },
+ // },
+
+ // // 우선순위 + 진행률 (콤팩트)
+ // {
+ // accessorKey: "progressPercentage",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="Priority/Progress" />
+ // ),
+ // cell: ({ row }) => {
+ // const doc = row.original
+ // const progress = doc.progressPercentage || 0
+ // const completed = doc.completedStages || 0
+ // const total = doc.totalStages || 0
+
+ // return (
+ // <div className="flex items-center gap-2">
+ // {doc.currentStagePriority && (
+ // <Badge
+ // variant={getPriorityColor(doc.currentStagePriority)}
+ // className="text-xs px-1.5 py-0"
+ // >
+ // {getPriorityText(doc.currentStagePriority)}
+ // </Badge>
+ // )}
+ // <div className="flex items-center gap-1">
+ // <Progress value={progress} className="w-12 h-1.5" />
+ // <span className="text-xs text-gray-600 dark:text-gray-400 min-w-[2rem]">
+ // {progress}%
+ // </span>
+ // </div>
+ // <span className="text-xs text-gray-500 dark:text-gray-400">
+ // ({completed}/{total})
+ // </span>
+ // </div>
+ // )
+ // },
+ // size: 140,
+ // enableResizing: true,
+ // meta: {
+ // excelHeader: "Progress"
+ // },
+ // },
// 업데이트 일시
{
diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts
index 2c65b4e6..77a03aae 100644
--- a/lib/vendor-document-list/plant/document-stages-service.ts
+++ b/lib/vendor-document-list/plant/document-stages-service.ts
@@ -4,7 +4,7 @@
import { revalidatePath, revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import db from "@/db/db"
-import {stageSubmissionView, codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema"
+import { stageSubmissionView, codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, stageIssueStages, stageDocuments, stageDocumentsView } from "@/db/schema"
import { and, eq, asc, desc, sql, inArray, max, ne, or, ilike } from "drizzle-orm"
import {
createDocumentSchema,
@@ -33,6 +33,7 @@ import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository
import { getServerSession } from "next-auth"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { ShiBuyerSystemAPI } from "./shi-buyer-system-api"
+import ExcelJS from 'exceljs'
interface UpdateDocumentData {
documentId: number
@@ -47,13 +48,13 @@ export async function updateDocument(data: UpdateDocumentData) {
try {
// 1. 문서 기본 정보 업데이트
const [updatedDocument] = await db
- .update(documents)
+ .update(stageDocuments)
.set({
title: data.title,
vendorDocNumber: data.vendorDocNumber || null,
updatedAt: new Date(),
})
- .where(eq(documents.id, data.documentId))
+ .where(eq(stageDocuments.id, data.documentId))
.returning()
if (!updatedDocument) {
@@ -63,12 +64,12 @@ export async function updateDocument(data: UpdateDocumentData) {
// 2. 스테이지들의 plan date 업데이트
const stageUpdatePromises = Object.entries(data.stagePlanDates).map(([stageId, planDate]) => {
return db
- .update(issueStages)
+ .update(stageIssueStages)
.set({
planDate: planDate || null,
updatedAt: new Date(),
})
- .where(eq(issueStages.id, Number(stageId)))
+ .where(eq(stageIssueStages.id, Number(stageId)))
})
await Promise.all(stageUpdatePromises)
@@ -93,8 +94,8 @@ export async function deleteDocument(input: { id: number }) {
const validatedData = deleteDocumentSchema.parse(input)
// 문서 존재 확인
- const existingDoc = await db.query.documents.findFirst({
- where: eq(documents.id, validatedData.id)
+ const existingDoc = await db.query.stageDocuments.findFirst({
+ where: eq(stageDocuments.id, validatedData.id)
})
if (!existingDoc) {
@@ -102,8 +103,8 @@ export async function deleteDocument(input: { id: number }) {
}
// 연관된 스테이지 확인
- const relatedStages = await db.query.issueStages.findMany({
- where: eq(issueStages.documentId, validatedData.id)
+ const relatedStages = await db.query.stageIssueStages.findMany({
+ where: eq(stageIssueStages.documentId, validatedData.id)
})
if (relatedStages.length > 0) {
@@ -112,16 +113,12 @@ export async function deleteDocument(input: { id: number }) {
// 소프트 삭제 (상태 변경)
await db
- .update(documents)
+ .update(stageDocuments)
.set({
status: "DELETED",
updatedAt: new Date(),
})
- .where(eq(documents.id, validatedData.id))
-
- // 캐시 무효화
- revalidateTag(`documents-${existingDoc.contractId}`)
- revalidatePath(`/contracts/${existingDoc.contractId}/documents`)
+ .where(eq(stageDocuments.id, validatedData.id))
return {
success: true,
@@ -143,16 +140,17 @@ interface DeleteDocumentsData {
export async function deleteDocuments(data: DeleteDocumentsData) {
try {
+
if (data.ids.length === 0) {
return { success: false, error: "삭제할 문서가 선택되지 않았습니다." }
}
/* 1. 요청한 문서가 존재하는지 확인 ------------------------------------ */
const existingDocs = await db
- .select({ id: documents.id, docNumber: documents.docNumber })
- .from(documents)
+ .select({ id: stageDocuments.id, docNumber: stageDocuments.docNumber })
+ .from(stageDocuments)
.where(and(
- inArray(documents.id, data.ids),
+ inArray(stageDocuments.id, data.ids),
))
if (existingDocs.length === 0) {
@@ -168,9 +166,9 @@ export async function deleteDocuments(data: DeleteDocumentsData) {
/* 2. 연관 스테이지 건수 파악(로그·메시지용) --------------------------- */
const relatedStages = await db
- .select({ documentId: issueStages.documentId })
- .from(issueStages)
- .where(inArray(issueStages.documentId, data.ids))
+ .select({ documentId: stageIssueStages.documentId })
+ .from(stageIssueStages)
+ .where(inArray(stageIssueStages.documentId, data.ids))
const stagesToDelete = relatedStages.length
@@ -178,17 +176,17 @@ export async function deleteDocuments(data: DeleteDocumentsData) {
// ─> FK에 ON DELETE CASCADE 가 있다면 생략 가능.
if (stagesToDelete > 0) {
await db
- .delete(issueStages)
- .where(inArray(issueStages.documentId, data.ids))
+ .delete(stageIssueStages)
+ .where(inArray(stageIssueStages.documentId, data.ids))
}
/* 4. 문서 하드 삭제 --------------------------------------------------- */
const deletedDocs = await db
- .delete(documents)
+ .delete(stageDocuments)
.where(and(
- inArray(documents.id, data.ids),
+ inArray(stageDocuments.id, data.ids),
))
- .returning({ id: documents.id, docNumber: documents.docNumber })
+ .returning({ id: stageDocuments.id, docNumber: stageDocuments.docNumber })
/* 5. 캐시 무효화 ------------------------------------------------------ */
@@ -225,8 +223,8 @@ export async function createStage(input: CreateStageInput) {
const validatedData = createStageSchema.parse(input)
// 문서 존재 확인
- const document = await db.query.documents.findFirst({
- where: eq(documents.id, validatedData.documentId)
+ const document = await db.query.stageDocuments.findFirst({
+ where: eq(stageDocuments.id, validatedData.documentId)
})
if (!document) {
@@ -234,10 +232,10 @@ export async function createStage(input: CreateStageInput) {
}
// 스테이지명 중복 검사
- const existingStage = await db.query.issueStages.findFirst({
+ const existingStage = await db.query.stageIssueStages.findFirst({
where: and(
- eq(issueStages.documentId, validatedData.documentId),
- eq(issueStages.stageName, validatedData.stageName)
+ eq(stageIssueStages.documentId, validatedData.documentId),
+ eq(stageIssueStages.stageName, validatedData.stageName)
)
})
@@ -249,15 +247,15 @@ export async function createStage(input: CreateStageInput) {
let stageOrder = validatedData.stageOrder
if (stageOrder === 0 || stageOrder === undefined) {
const maxOrderResult = await db
- .select({ maxOrder: max(issueStages.stageOrder) })
- .from(issueStages)
- .where(eq(issueStages.documentId, validatedData.documentId))
+ .select({ maxOrder: max(stageIssueStages.stageOrder) })
+ .from(stageIssueStages)
+ .where(eq(stageIssueStages.documentId, validatedData.documentId))
stageOrder = (maxOrderResult[0]?.maxOrder ?? -1) + 1
}
// 스테이지 생성
- const [newStage] = await db.insert(issueStages).values({
+ const [newStage] = await db.insert(stageIssueStages).values({
documentId: validatedData.documentId,
stageName: validatedData.stageName,
planDate: validatedData.planDate || null,
@@ -273,10 +271,6 @@ export async function createStage(input: CreateStageInput) {
updatedAt: new Date(),
}).returning()
- // 캐시 무효화
- revalidateTag(`documents-${document.contractId}`)
- revalidateTag(`document-${validatedData.documentId}`)
- revalidatePath(`/contracts/${document.contractId}/documents`)
return {
success: true,
@@ -301,8 +295,8 @@ export async function updateStage(input: UpdateStageInput) {
const validatedData = updateStageSchema.parse(input)
// 스테이지 존재 확인
- const existingStage = await db.query.issueStages.findFirst({
- where: eq(issueStages.id, validatedData.id),
+ const existingStage = await db.query.stageIssueStages.findFirst({
+ where: eq(stageIssueStages.id, validatedData.id),
with: {
document: true
}
@@ -314,10 +308,10 @@ export async function updateStage(input: UpdateStageInput) {
// 스테이지명 중복 검사 (스테이지명 변경 시)
if (validatedData.stageName && validatedData.stageName !== existingStage.stageName) {
- const duplicateStage = await db.query.issueStages.findFirst({
+ const duplicateStage = await db.query.stageIssueStages.findFirst({
where: and(
- eq(issueStages.documentId, existingStage.documentId),
- eq(issueStages.stageName, validatedData.stageName)
+ eq(stageIssueStages.documentId, existingStage.documentId),
+ eq(stageIssueStages.stageName, validatedData.stageName)
)
})
@@ -328,18 +322,14 @@ export async function updateStage(input: UpdateStageInput) {
// 스테이지 업데이트
const [updatedStage] = await db
- .update(issueStages)
+ .update(stageIssueStages)
.set({
...validatedData,
updatedAt: new Date(),
})
- .where(eq(issueStages.id, validatedData.id))
+ .where(eq(stageIssueStages.id, validatedData.id))
.returning()
- // 캐시 무효화
- revalidateTag(`documents-${existingStage.document.contractId}`)
- revalidateTag(`document-${existingStage.documentId}`)
- revalidatePath(`/contracts/${existingStage.document.contractId}/documents`)
return {
success: true,
@@ -364,8 +354,8 @@ export async function deleteStage(input: { id: number }) {
const validatedData = deleteStageSchema.parse(input)
// 스테이지 존재 확인
- const existingStage = await db.query.issueStages.findFirst({
- where: eq(issueStages.id, validatedData.id),
+ const existingStage = await db.query.stageIssueStages.findFirst({
+ where: eq(stageIssueStages.id, validatedData.id),
with: {
document: true
}
@@ -385,28 +375,23 @@ export async function deleteStage(input: { id: number }) {
// }
// 스테이지 삭제
- await db.delete(issueStages).where(eq(issueStages.id, validatedData.id))
+ await db.delete(stageIssueStages).where(eq(stageIssueStages.id, validatedData.id))
// 스테이지 순서 재정렬
- const remainingStages = await db.query.issueStages.findMany({
- where: eq(issueStages.documentId, existingStage.documentId),
- orderBy: [issueStages.stageOrder]
+ const remainingStages = await db.query.stageIssueStages.findMany({
+ where: eq(stageIssueStages.documentId, existingStage.documentId),
+ orderBy: [stageIssueStages.stageOrder]
})
for (let i = 0; i < remainingStages.length; i++) {
if (remainingStages[i].stageOrder !== i) {
await db
- .update(issueStages)
+ .update(stageIssueStages)
.set({ stageOrder: i, updatedAt: new Date() })
- .where(eq(issueStages.id, remainingStages[i].id))
+ .where(eq(stageIssueStages.id, remainingStages[i].id))
}
}
- // 캐시 무효화
- revalidateTag(`documents-${existingStage.document.contractId}`)
- revalidateTag(`document-${existingStage.documentId}`)
- revalidatePath(`/contracts/${existingStage.document.contractId}/documents`)
-
return {
success: true,
message: "스테이지가 성공적으로 삭제되었습니다"
@@ -432,8 +417,8 @@ export async function reorderStages(input: any) {
validateStageOrder(validatedData.stages)
// 문서 존재 확인
- const document = await db.query.documents.findFirst({
- where: eq(documents.id, validatedData.documentId)
+ const document = await db.query.stageDocuments.findFirst({
+ where: eq(stageDocuments.id, validatedData.documentId)
})
if (!document) {
@@ -442,10 +427,10 @@ export async function reorderStages(input: any) {
// 스테이지들이 해당 문서에 속하는지 확인
const stageIds = validatedData.stages.map(s => s.id)
- const existingStages = await db.query.issueStages.findMany({
+ const existingStages = await db.query.stageIssueStages.findMany({
where: and(
- eq(issueStages.documentId, validatedData.documentId),
- inArray(issueStages.id, stageIds)
+ eq(stageIssueStages.documentId, validatedData.documentId),
+ inArray(stageIssueStages.id, stageIds)
)
})
@@ -457,19 +442,15 @@ export async function reorderStages(input: any) {
await db.transaction(async (tx) => {
for (const stage of validatedData.stages) {
await tx
- .update(issueStages)
+ .update(stageIssueStages)
.set({
stageOrder: stage.stageOrder,
updatedAt: new Date()
})
- .where(eq(issueStages.id, stage.id))
+ .where(eq(stageIssueStages.id, stage.id))
}
})
- // 캐시 무효화
- revalidateTag(`documents-${document.contractId}`)
- revalidateTag(`document-${validatedData.documentId}`)
- revalidatePath(`/contracts/${document.contractId}/documents`)
return {
success: true,
@@ -497,7 +478,7 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult
const validatedData = bulkCreateDocumentsSchema.parse(input)
const result: ExcelImportResult = {
- totalRows: validatedData.documents.length,
+ totalRows: validatedData.stageDocuments.length,
successCount: 0,
failureCount: 0,
errors: [],
@@ -506,16 +487,15 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult
// 트랜잭션으로 일괄 처리
await db.transaction(async (tx) => {
- for (let i = 0; i < validatedData.documents.length; i++) {
- const docData = validatedData.documents[i]
+ for (let i = 0; i < validatedData.stageDocuments.length; i++) {
+ const docData = validatedData.stageDocuments[i]
try {
// 문서번호 중복 검사
- const existingDoc = await tx.query.documents.findFirst({
+ const existingDoc = await tx.query.stageDocuments.findFirst({
where: and(
- eq(documents.contractId, validatedData.contractId),
- eq(documents.docNumber, docData.docNumber),
- eq(documents.status, "ACTIVE")
+ eq(stageDocuments.docNumber, docData.docNumber),
+ eq(stageDocuments.status, "ACTIVE")
)
})
@@ -530,7 +510,7 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult
}
// 문서 생성
- const [newDoc] = await tx.insert(documents).values({
+ const [newDoc] = await tx.insert(stageDocuments).values({
contractId: validatedData.contractId,
docNumber: docData.docNumber,
title: docData.title,
@@ -566,9 +546,6 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult
}
})
- // 캐시 무효화
- revalidateTag(`documents-${validatedData.contractId}`)
- revalidatePath(`/contracts/${validatedData.contractId}/documents`)
return result
@@ -586,8 +563,8 @@ export async function bulkUpdateStageStatus(input: any) {
const validatedData = bulkUpdateStatusSchema.parse(input)
// 스테이지들 존재 확인
- const existingStages = await db.query.issueStages.findMany({
- where: inArray(issueStages.id, validatedData.stageIds),
+ const existingStages = await db.query.stageIssueStages.findMany({
+ where: inArray(stageIssueStages.id, validatedData.stageIds),
with: { document: true }
})
@@ -597,20 +574,15 @@ export async function bulkUpdateStageStatus(input: any) {
// 일괄 업데이트
await db
- .update(issueStages)
+ .update(stageIssueStages)
.set({
stageStatus: validatedData.status,
actualDate: validatedData.actualDate || null,
updatedAt: new Date()
})
- .where(inArray(issueStages.id, validatedData.stageIds))
+ .where(inArray(stageIssueStages.id, validatedData.stageIds))
// 관련된 계약들의 캐시 무효화
- const contractIds = [...new Set(existingStages.map(s => s.document.contractId))]
- for (const contractId of contractIds) {
- revalidateTag(`documents-${contractId}`)
- revalidatePath(`/contracts/${contractId}/documents`)
- }
return {
success: true,
@@ -634,8 +606,8 @@ export async function bulkAssignStages(input: any) {
const validatedData = bulkAssignSchema.parse(input)
// 스테이지들 존재 확인
- const existingStages = await db.query.issueStages.findMany({
- where: inArray(issueStages.id, validatedData.stageIds),
+ const existingStages = await db.query.stageIssueStages.findMany({
+ where: inArray(stageIssueStages.id, validatedData.stageIds),
with: { document: true }
})
@@ -645,20 +617,14 @@ export async function bulkAssignStages(input: any) {
// 일괄 담당자 지정
await db
- .update(issueStages)
+ .update(stageIssueStages)
.set({
assigneeId: validatedData.assigneeId || null,
assigneeName: validatedData.assigneeName || null,
updatedAt: new Date()
})
- .where(inArray(issueStages.id, validatedData.stageIds))
+ .where(inArray(stageIssueStages.id, validatedData.stageIds))
- // 관련된 계약들의 캐시 무효화
- const contractIds = [...new Set(existingStages.map(s => s.document.contractId))]
- for (const contractId of contractIds) {
- revalidateTag(`documents-${contractId}`)
- revalidatePath(`/contracts/${contractId}/documents`)
- }
return {
success: true,
@@ -689,12 +655,12 @@ export async function getDocumentNumberTypes(contractId: number) {
}
}
- console.log(project,"project")
+ console.log(project, "project")
const types = await db
.select()
.from(documentNumberTypes)
- .where(and (eq(documentNumberTypes.isActive, true), eq(documentNumberTypes.projectId,project.projectId)))
+ .where(and(eq(documentNumberTypes.isActive, true), eq(documentNumberTypes.projectId, project.projectId)))
.orderBy(asc(documentNumberTypes.name))
return { success: true, data: types }
@@ -735,7 +701,7 @@ export async function getDocumentNumberTypeConfigs(documentNumberTypeId: number)
.where(
and(
eq(documentNumberTypeConfigs.documentNumberTypeId, documentNumberTypeId),
- eq(documentNumberTypeConfigs.isActive, true),
+ eq(documentNumberTypeConfigs.isActive, true),
// eq(documentNumberTypeConfigs.projectId, project.projectId)
)
)
@@ -772,9 +738,9 @@ export async function getComboBoxOptions(codeGroupId: number) {
}
// 문서 클래스 목록 조회
-export async function getDocumentClasses(contractId:number) {
+export async function getDocumentClasses(contractId: number) {
try {
- const projectId = await db.query.contracts.findFirst({
+ const projectId = await db.query.contracts.findFirst({
where: eq(contracts.id, contractId),
});
@@ -788,10 +754,10 @@ export async function getDocumentClasses(contractId:number) {
.select()
.from(documentClasses)
.where(
- and(
- eq(documentClasses.isActive, true),
- eq(documentClasses.projectId, projectId.projectId)
- )
+ and(
+ eq(documentClasses.isActive, true),
+ eq(documentClasses.projectId, projectId.projectId)
+ )
)
.orderBy(asc(documentClasses.description))
@@ -871,7 +837,7 @@ export async function getDocumentClassOptionsByContract(contractId: number) {
eq(documentClassOptions.isActive, true)
)
);
- // 필요하면 .orderBy(asc(documentClassOptions.sortOrder ?? documentClassOptions.optionValue))
+ // 필요하면 .orderBy(asc(documentClassOptions.sortOrder ?? documentClassOptions.optionValue))
return { success: true, data: { classes, options } };
} catch (error) {
@@ -924,7 +890,7 @@ export async function createDocument(data: CreateDocumentData) {
},
})
- console.log(contract,"contract")
+ console.log(contract, "contract")
if (!contract) {
return { success: false, error: "유효하지 않은 계약(ID)입니다." }
@@ -944,7 +910,7 @@ export async function createDocument(data: CreateDocumentData) {
/* ──────────────────────────────── 3. 문서 레코드 삽입 ─────────────────────────────── */
const insertData = {
// 필수
- projectId,
+ projectId,
vendorId, // ★ 새로 추가
contractId: data.contractId,
docNumber: data.docNumber,
@@ -954,7 +920,7 @@ export async function createDocument(data: CreateDocumentData) {
updatedAt: new Date(),
// 선택
- vendorDocNumber: data.vendorDocNumber === null || data.vendorDocNumber ==='' ? null: data.vendorDocNumber ,
+ vendorDocNumber: data.vendorDocNumber === null || data.vendorDocNumber === '' ? null : data.vendorDocNumber,
}
@@ -984,7 +950,7 @@ export async function createDocument(data: CreateDocumentData) {
)
- console.log(data.documentClassId,"documentClassId")
+ console.log(data.documentClassId, "documentClassId")
console.log(stageOptionsResult.data)
if (stageOptionsResult.success && stageOptionsResult.data.length > 0) {
@@ -1045,7 +1011,7 @@ export async function getDocumentStagesOnly(
// 세션에서 도메인 정보 가져오기
const session = await getServerSession(authOptions)
const isEvcpDomain = session?.user?.domain === "evcp"
-
+
// 도메인별 WHERE 조건 설정
let finalWhere
if (isEvcpDomain) {
@@ -1065,14 +1031,14 @@ export async function getDocumentStagesOnly(
- // 정렬 처리
+ // 정렬 처리
const orderBy = input.sort && input.sort.length > 0
? input.sort.map((item) =>
item.desc
? desc(stageDocumentsView[item.id])
: asc(stageDocumentsView[item.id])
)
- : [desc(stageDocumentsView.createdAt)]
+ : [desc(stageDocumentsView.createdAt)]
// 트랜잭션 실행
@@ -1195,10 +1161,10 @@ export async function sendDocumentsToSHI(contractId: number) {
try {
const api = new ShiBuyerSystemAPI()
const result = await api.sendToSHI(contractId)
-
+
// 캐시 무효화
revalidatePath(`/partners/document-list-only/${contractId}`)
-
+
return result
} catch (error) {
console.error("SHI 전송 실패:", error)
@@ -1215,10 +1181,10 @@ export async function pullDocumentStatusFromSHI(
try {
const api = new ShiBuyerSystemAPI()
const result = await api.pullDocumentStatus(contractId)
-
+
// 캐시 무효화
revalidatePath(`/partners/document-list-only/${contractId}`)
-
+
return result
} catch (error) {
console.error("문서 상태 풀링 실패:", error)
@@ -1254,10 +1220,10 @@ export async function validateFiles(files: FileValidation[]): Promise<Validation
if (!session?.user?.companyId) {
throw new Error("Unauthorized")
}
-
+
const vendorId = session.user.companyId
const results: ValidationResult[] = []
-
+
for (const file of files) {
// stageSubmissionView에서 매칭되는 레코드 찾기
const match = await db
@@ -1277,7 +1243,7 @@ export async function validateFiles(files: FileValidation[]): Promise<Validation
)
)
.limit(1)
-
+
if (match.length > 0) {
results.push({
projectId: file.projectId,
@@ -1298,6 +1264,238 @@ export async function validateFiles(files: FileValidation[]): Promise<Validation
})
}
}
-
+
return results
-} \ No newline at end of file
+}
+
+
+
+// =============================================================================
+// Type Definitions (서버와 클라이언트 공유)
+// =============================================================================
+export interface ParsedDocument {
+ docNumber: string
+ title: string
+ documentClass: string
+ vendorDocNumber?: string
+ notes?: string
+}
+
+export interface ParsedStage {
+ docNumber: string
+ stageName: string
+ planDate?: string
+}
+
+interface UploadData {
+ contractId: number
+ documents: ParsedDocument[]
+ stages: ParsedStage[]
+ projectType: "ship" | "plant"
+}
+
+// =============================================================================
+// Upload Import Data (서버 액션)
+// =============================================================================
+export async function uploadImportData(data: UploadData) {
+ const { contractId, documents, stages, projectType } = data
+ const warnings: string[] = []
+ const createdDocumentIds: number[] = []
+ const documentIdMap = new Map<string, number>() // docNumber -> documentId
+
+ try {
+ const contract = await db.query.contracts.findFirst({
+ where: eq(contracts.id, contractId),
+ });
+
+ if (!contract ) {
+ throw new Error("Contract not found")
+ }
+
+
+ // 2. Document Class 매핑 가져오기 (트랜잭션 밖에서)
+ const documentClassesData = await db
+ .select({
+ id: documentClasses.id,
+ value: documentClasses.value,
+ description: documentClasses.description,
+ })
+ .from(documentClasses)
+ .where(and(eq(documentClasses.projectId, contract.projectId), eq(documentClasses.isActive, true)))
+
+ const classMap = new Map(
+ documentClassesData.map(dc => [dc.value, dc.id])
+ )
+
+ console.log(classMap)
+
+ // 3. 각 문서를 개별적으로 처리 (개별 트랜잭션)
+ for (const doc of documents) {
+ console.log(doc)
+ const documentClassId = classMap.get(doc.documentClass)
+
+ if (!documentClassId) {
+ warnings.push(`Document Class "${doc.documentClass}"를 찾을 수 없습니다 (문서: ${doc.docNumber})`)
+ continue
+ }
+
+ try {
+ // 개별 트랜잭션으로 각 문서 처리
+ const result = await db.transaction(async (tx) => {
+ // 먼저 문서가 이미 존재하는지 확인
+ const [existingDoc] = await tx
+ .select({ id: stageDocuments.id })
+ .from(stageDocuments)
+ .where(
+ and(
+ eq(stageDocuments.projectId, contract.projectId),
+ eq(stageDocuments.docNumber, doc.docNumber),
+ eq(stageDocuments.status, "ACTIVE")
+ )
+ )
+ .limit(1)
+
+ if (existingDoc) {
+ throw new Error(`문서번호 "${doc.docNumber}"가 이미 존재합니다`)
+ }
+
+ // 3-1. 문서 생성
+ const [newDoc] = await tx
+ .insert(stageDocuments)
+ .values({
+ docNumber: doc.docNumber,
+ title: doc.title,
+ vendorDocNumber: doc.vendorDocNumber || null,
+ projectId:contract.projectId,
+ vendorId:contract.vendorId,
+ contractId,
+ status: "ACTIVE",
+ syncStatus: "pending",
+ syncVersion: 0,
+ })
+ .returning({ id: stageDocuments.id })
+
+ if (!newDoc) {
+ throw new Error(`문서 생성 실패: ${doc.docNumber}`)
+ }
+
+ // 3-2. Document Class Options에서 스테이지 자동 생성
+ const classOptions = await db
+ .select()
+ .from(documentClassOptions)
+ .where(
+ and(
+ eq(documentClassOptions.documentClassId, documentClassId),
+ eq(documentClassOptions.isActive, true)
+ )
+ )
+ .orderBy(asc(documentClassOptions.sdq))
+
+
+ // 3-3. 각 옵션에 대해 스테이지 생성
+ const stageInserts = []
+
+ console.log(documentClassId, "documentClassId")
+ console.log(classOptions, "classOptions")
+
+ for (const option of classOptions) {
+ // stages 배열에서 해당 스테이지의 plan date 찾기
+ const stageData = stages.find(
+ s => s.docNumber === doc.docNumber && s.stageName === option.description
+ )
+
+ stageInserts.push({
+ documentId: newDoc.id,
+ stageName: option.description,
+ stageOrder: option.sdq,
+ stageStatus: "PLANNED" as const,
+ priority: "MEDIUM" as const,
+ planDate: stageData?.planDate || null,
+ reminderDays: 3,
+ })
+ }
+
+ // 모든 스테이지를 한번에 삽입
+ if (stageInserts.length > 0) {
+ await tx.insert(stageIssueStages).values(stageInserts)
+ }
+
+ return newDoc.id
+ })
+
+ createdDocumentIds.push(result)
+ documentIdMap.set(doc.docNumber, result)
+
+ } catch (error) {
+ console.error(`Error creating document ${doc.docNumber}:`, error)
+ warnings.push(`문서 생성 중 오류: ${doc.docNumber} - ${error instanceof Error ? error.message : 'Unknown error'}`)
+ }
+ }
+
+ // 4. 기존 문서의 Plan Date 업데이트 (신규 생성된 문서는 제외)
+ const processedDocNumbers = new Set(documents.map(d => d.docNumber))
+
+ for (const stage of stages) {
+ if (!stage.planDate) continue
+
+ // 이미 처리된 신규 문서는 제외
+ if (processedDocNumbers.has(stage.docNumber)) continue
+
+ try {
+ // 기존 문서 찾기
+ const [existingDoc] = await db
+ .select({ id: stageDocuments.id })
+ .from(stageDocuments)
+ .where(
+ and(
+ eq(stageDocuments.projectId, contract.projectId),
+ eq(stageDocuments.docNumber, stage.docNumber),
+ eq(stageDocuments.status, "ACTIVE")
+ )
+ )
+ .limit(1)
+
+ if (!existingDoc) {
+ warnings.push(`스테이지 업데이트 실패: 문서 "${stage.docNumber}"를 찾을 수 없습니다`)
+ continue
+ }
+
+ // 스테이지 plan date 업데이트
+ await db
+ .update(stageIssueStages)
+ .set({
+ planDate: stage.planDate,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(stageIssueStages.documentId, existingDoc.id),
+ eq(stageIssueStages.stageName, stage.stageName)
+ )
+ )
+ } catch (error) {
+ console.error(`Error updating stage for document ${stage.docNumber}:`, error)
+ warnings.push(`스테이지 "${stage.stageName}" 업데이트 실패 (문서: ${stage.docNumber})`)
+ }
+ }
+
+ return {
+ success: true,
+ data: {
+ success: true,
+ createdCount: createdDocumentIds.length,
+ documentIds: createdDocumentIds
+ },
+ warnings,
+ message: `${createdDocumentIds.length}개 문서가 성공적으로 생성되었습니다`
+ }
+
+ } catch (error) {
+ console.error("Upload import data error:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "데이터 업로드 중 오류가 발생했습니다",
+ warnings
+ }
+ }
+}
diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx
index 50d54a92..6cc112e3 100644
--- a/lib/vendor-document-list/plant/document-stages-table.tsx
+++ b/lib/vendor-document-list/plant/document-stages-table.tsx
@@ -14,11 +14,11 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
- AlertTriangle,
- Clock,
- TrendingUp,
- Target,
- Users,
+ FileText,
+ Send,
+ Search,
+ CheckCircle2,
+ XCircle,
Plus,
FileSpreadsheet
} from "lucide-react"
@@ -32,7 +32,6 @@ import { DocumentStagesExpandedContent } from "./document-stages-expanded-conten
import { AddDocumentDialog, DeleteDocumentsDialog } from "./document-stage-dialogs"
import { EditDocumentDialog } from "./document-stage-dialogs"
import { EditStageDialog } from "./document-stage-dialogs"
-import { ExcelImportDialog } from "./document-stage-dialogs"
import { DocumentsTableToolbarActions } from "./document-stage-toolbar"
import { useSession } from "next-auth/react"
@@ -51,7 +50,6 @@ export function DocumentStagesTable({
const { data: session } = useSession()
-
// URL에서 언어 파라미터 가져오기
const params = useParams()
const lng = (params?.lng as string) || 'ko'
@@ -63,7 +61,7 @@ export function DocumentStagesTable({
// 상태 관리
const [rowAction, setRowAction] = React.useState<DataTableRowAction<StageDocumentsView> | null>(null)
const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set())
- const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all')
+ const [quickFilter, setQuickFilter] = React.useState<'all' | 'submitted' | 'under_review' | 'approved' | 'rejected'>('all')
// 다이얼로그 상태들
const [addDocumentOpen, setAddDocumentOpen] = React.useState(false)
@@ -112,52 +110,41 @@ export function DocumentStagesTable({
[expandedRows, projectType, currentDomain]
)
- // 통계 계산
+ // 문서 상태별 통계 계산
const stats = React.useMemo(() => {
- console.log('DocumentStagesTable - data:', data)
- console.log('DocumentStagesTable - data length:', data?.length)
-
const totalDocs = data?.length || 0
- const overdue = data?.filter(doc => doc.isOverdue)?.length || 0
- const dueSoon = data?.filter(doc =>
- doc.daysUntilDue !== null &&
- doc.daysUntilDue >= 0 &&
- doc.daysUntilDue <= 3
+ const submitted = data?.filter(doc => doc.status === 'SUBMITTED')?.length || 0
+ const underReview = data?.filter(doc => doc.status === 'UNDER_REVIEW')?.length || 0
+ const approved = data?.filter(doc => doc.status === 'APPROVED')?.length || 0
+ const rejected = data?.filter(doc => doc.status === 'REJECTED')?.length || 0
+ const notSubmitted = data?.filter(doc =>
+ !doc.status || !['SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED'].includes(doc.status)
)?.length || 0
- const inProgress = data?.filter(doc => doc.currentStageStatus === 'IN_PROGRESS')?.length || 0
- const highPriority = data?.filter(doc => doc.currentStagePriority === 'HIGH')?.length || 0
- const avgProgress = totalDocs > 0
- ? Math.round((data?.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) || 0) / totalDocs)
- : 0
- const result = {
+ return {
total: totalDocs,
- overdue,
- dueSoon,
- inProgress,
- highPriority,
- avgProgress
+ submitted,
+ underReview,
+ approved,
+ rejected,
+ notSubmitted,
+ approvalRate: totalDocs > 0
+ ? Math.round((approved / totalDocs) * 100)
+ : 0
}
-
- console.log('DocumentStagesTable - stats:', result)
- return result
}, [data])
// 빠른 필터링
const filteredData = React.useMemo(() => {
switch (quickFilter) {
- case 'overdue':
- return data.filter(doc => doc.isOverdue)
- case 'due_soon':
- return data.filter(doc =>
- doc.daysUntilDue !== null &&
- doc.daysUntilDue >= 0 &&
- doc.daysUntilDue <= 3
- )
- case 'in_progress':
- return data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS')
- case 'high_priority':
- return data.filter(doc => doc.currentStagePriority === 'HIGH')
+ case 'submitted':
+ return data.filter(doc => doc.status === 'SUBMITTED')
+ case 'under_review':
+ return data.filter(doc => doc.status === 'UNDER_REVIEW')
+ case 'approved':
+ return data.filter(doc => doc.status === 'APPROVED')
+ case 'rejected':
+ return data.filter(doc => doc.status === 'REJECTED')
default:
return data
}
@@ -172,24 +159,6 @@ export function DocumentStagesTable({
setExcelImportOpen(true)
}
- const handleBulkAction = async (action: string, selectedRows: any[]) => {
- try {
- if (action === 'bulk_complete') {
- const stageIds = selectedRows
- .map(row => row.original.currentStageId)
- .filter(Boolean)
-
- if (stageIds.length > 0) {
- toast.success(t('documentList.messages.stageCompletionSuccess', { count: stageIds.length }))
- }
- } else if (action === 'bulk_assign') {
- toast.info(t('documentList.messages.bulkAssignPending'))
- }
- } catch (error) {
- toast.error(t('documentList.messages.bulkActionError'))
- }
- }
-
const closeAllDialogs = () => {
setAddDocumentOpen(false)
setEditDocumentOpen(false)
@@ -201,8 +170,7 @@ export function DocumentStagesTable({
}
// 필터 필드 정의
- const filterFields: DataTableFilterField<StageDocumentsView>[] = [
- ]
+ const filterFields: DataTableFilterField<StageDocumentsView>[] = []
const advancedFilterFields: DataTableAdvancedFilterField<StageDocumentsView>[] = [
{
@@ -216,37 +184,18 @@ export function DocumentStagesTable({
type: "text",
},
{
- id: "currentStageStatus",
- label: "스테이지 상태",
+ id: "status",
+ label: "문서 상태",
type: "select",
options: [
- { label: "계획됨", value: "PLANNED" },
- { label: "진행중", value: "IN_PROGRESS" },
{ label: "제출됨", value: "SUBMITTED" },
- { label: "완료됨", value: "COMPLETED" },
- ],
- },
- {
- id: "currentStagePriority",
- label: "우선순위",
- type: "select",
- options: [
- { label: "높음", value: "HIGH" },
- { label: "보통", value: "MEDIUM" },
- { label: "낮음", value: "LOW" },
- ],
- },
- {
- id: "isOverdue",
- label: "지연 여부",
- type: "select",
- options: [
- { label: "지연됨", value: "true" },
- { label: "정상", value: "false" },
+ { label: "검토중", value: "UNDER_REVIEW" },
+ { label: "승인됨", value: "APPROVED" },
+ { label: "반려됨", value: "REJECTED" },
],
},
{
- id: "currentStageAssigneeName",
+ id: "pic",
label: "담당자",
type: "text",
},
@@ -276,95 +225,111 @@ export function DocumentStagesTable({
return (
<div className="space-y-6">
- {/* 통계 대시보드 */}
+ {/* 문서 상태 대시보드 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+ {/* 전체 문서 */}
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">{t('documentList.dashboard.totalDocuments')}</CardTitle>
- <TrendingUp className="h-4 w-4 text-muted-foreground" />
+ <CardTitle className="text-sm font-medium">Total Documents</CardTitle>
+ <FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground">
- {t('documentList.dashboard.totalDocumentCount', { total: stats.total })}
+ 전체 등록 문서
</p>
</CardContent>
</Card>
- <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}>
+ {/* 제출됨 */}
+ <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('submitted')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">{t('documentList.dashboard.overdueDocuments')}</CardTitle>
- <AlertTriangle className="h-4 w-4 text-red-500" />
+ <CardTitle className="text-sm font-medium">Submitted</CardTitle>
+ <Send className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
- <div className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.overdue}</div>
- <p className="text-xs text-muted-foreground">{t('documentList.dashboard.checkImmediately')}</p>
+ <div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.submitted}</div>
+ <p className="text-xs text-muted-foreground">제출 대기중</p>
</CardContent>
</Card>
- <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}>
+ {/* 검토중 */}
+ {/* <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('under_review')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">{t('documentList.dashboard.dueSoonDocuments')}</CardTitle>
- <Clock className="h-4 w-4 text-orange-500" />
+ <CardTitle className="text-sm font-medium">Under Review</CardTitle>
+ <Search className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
- <div className="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.dueSoon}</div>
- <p className="text-xs text-muted-foreground">{t('documentList.dashboard.dueInDays')}</p>
+ <div className="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.underReview}</div>
+ <p className="text-xs text-muted-foreground">검토 진행중</p>
+ </CardContent>
+ </Card> */}
+
+ {/* 승인됨 */}
+ <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('approved')}>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">Approved</CardTitle>
+ <CheckCircle2 className="h-4 w-4 text-green-500" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.approved}</div>
+ <p className="text-xs text-muted-foreground">승인 완료 ({stats.approvalRate}%)</p>
</CardContent>
</Card>
- <Card>
+ <Card className="cursor-pointer hover:shadow-md transition-shadow border-red-200 dark:border-red-800"
+ onClick={() => setQuickFilter('rejected')}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">{t('documentList.dashboard.averageProgress')}</CardTitle>
- <Target className="h-4 w-4 text-green-500" />
+ <CardTitle className="text-sm font-medium">Rejected</CardTitle>
+ <XCircle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
- <div className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.avgProgress}%</div>
- <p className="text-xs text-muted-foreground">{t('documentList.dashboard.overallProgress')}</p>
+ <div className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.rejected}</div>
+ <p className="text-xs text-muted-foreground">재작업 필요</p>
</CardContent>
</Card>
</div>
- {/* 빠른 필터 */}
+ {/* 빠른 필터 뱃지 */}
<div className="flex gap-2 overflow-x-auto pb-2">
<Badge
variant={quickFilter === 'all' ? 'default' : 'outline'}
className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap"
onClick={() => setQuickFilter('all')}
>
- {t('documentList.quickFilters.all')} ({stats.total})
+ 전체 ({stats.total})
</Badge>
<Badge
- variant={quickFilter === 'overdue' ? 'destructive' : 'outline'}
- className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap"
- onClick={() => setQuickFilter('overdue')}
+ variant={quickFilter === 'submitted' ? 'default' : 'outline'}
+ className="cursor-pointer hover:bg-blue-500 hover:text-white dark:hover:bg-blue-600 whitespace-nowrap"
+ onClick={() => setQuickFilter('submitted')}
>
- <AlertTriangle className="w-3 h-3 mr-1" />
- {t('documentList.quickFilters.overdue')} ({stats.overdue})
+ <Send className="w-3 h-3 mr-1" />
+ 제출됨 ({stats.submitted})
</Badge>
<Badge
- variant={quickFilter === 'due_soon' ? 'default' : 'outline'}
+ variant={quickFilter === 'under_review' ? 'default' : 'outline'}
className="cursor-pointer hover:bg-orange-500 hover:text-white dark:hover:bg-orange-600 whitespace-nowrap"
- onClick={() => setQuickFilter('due_soon')}
+ onClick={() => setQuickFilter('under_review')}
>
- <Clock className="w-3 h-3 mr-1" />
- {t('documentList.quickFilters.dueSoon')} ({stats.dueSoon})
+ <Search className="w-3 h-3 mr-1" />
+ 검토중 ({stats.underReview})
</Badge>
<Badge
- variant={quickFilter === 'in_progress' ? 'default' : 'outline'}
- className="cursor-pointer hover:bg-blue-500 hover:text-white dark:hover:bg-blue-600 whitespace-nowrap"
- onClick={() => setQuickFilter('in_progress')}
+ variant={quickFilter === 'approved' ? 'success' : 'outline'}
+ className="cursor-pointer hover:bg-green-500 hover:text-white dark:hover:bg-green-600 whitespace-nowrap"
+ onClick={() => setQuickFilter('approved')}
>
- <Users className="w-3 h-3 mr-1" />
- {t('documentList.quickFilters.inProgress')} ({stats.inProgress})
+ <CheckCircle2 className="w-3 h-3 mr-1" />
+ 승인됨 ({stats.approved})
</Badge>
<Badge
- variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'}
+ variant={quickFilter === 'rejected' ? 'destructive' : 'outline'}
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap"
- onClick={() => setQuickFilter('high_priority')}
+ onClick={() => setQuickFilter('rejected')}
>
- <Target className="w-3 h-3 mr-1" />
- {t('documentList.quickFilters.highPriority')} ({stats.highPriority})
+ <XCircle className="w-3 h-3 mr-1" />
+ 반려됨 ({stats.rejected})
</Badge>
</div>
@@ -375,6 +340,7 @@ export function DocumentStagesTable({
table={table}
expandable={true}
expandedRows={expandedRows}
+ simpleExpansion={true}
setExpandedRows={setExpandedRows}
renderExpandedContent={(document) => (
<DocumentStagesExpandedContent
@@ -440,25 +406,13 @@ export function DocumentStagesTable({
stageId={selectedStageId}
/>
- <ExcelImportDialog
- open={excelImportOpen}
- onOpenChange={(open) => {
- if (!open) closeAllDialogs()
- else setExcelImportOpen(open)
- }}
- contractId={contractId}
- projectType={projectType}
- />
-
<DeleteDocumentsDialog
open={rowAction?.type === "delete"}
onOpenChange={() => setRowAction(null)}
showTrigger={false}
- documents={rowAction?.row.original ? [rowAction?.row.original] : []} // 전체 문서 배열
+ documents={rowAction?.row.original ? [rowAction?.row.original] : []}
onSuccess={() => rowAction?.row.toggleSelected(false)}
/>
-
-
</div>
)
} \ No newline at end of file
diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts
deleted file mode 100644
index c1409205..00000000
--- a/lib/vendor-document-list/plant/excel-import-export.ts
+++ /dev/null
@@ -1,788 +0,0 @@
-// excel-import-export.ts
-"use client"
-
-import ExcelJS from 'exceljs'
-import {
- excelDocumentRowSchema,
- excelStageRowSchema,
- type ExcelDocumentRow,
- type ExcelStageRow,
- type ExcelImportResult,
- type CreateDocumentInput
-} from './document-stage-validations'
-import { StageDocumentsView } from '@/db/schema'
-
-// =============================================================================
-// 1. 엑셀 템플릿 생성 및 다운로드
-// =============================================================================
-
-// 문서 템플릿 생성
-export async function createDocumentTemplate(projectType: "ship" | "plant") {
- const workbook = new ExcelJS.Workbook()
- const worksheet = workbook.addWorksheet("문서목록", {
- properties: { defaultColWidth: 15 }
- })
-
- const baseHeaders = [
- "문서번호*",
- "문서명*",
- "문서종류*",
- "PIC",
- "발행일",
- "설명"
- ]
-
- const plantHeaders = [
- "벤더문서번호",
- "벤더명",
- "벤더코드"
- ]
-
- const b4Headers = [
- "C구분",
- "D구분",
- "Degree구분",
- "부서구분",
- "S구분",
- "J구분"
- ]
-
- const headers = [
- ...baseHeaders,
- ...(projectType === "plant" ? plantHeaders : []),
- ...b4Headers
- ]
-
- // 헤더 행 추가 및 스타일링
- const headerRow = worksheet.addRow(headers)
- headerRow.eachCell((cell, colNumber) => {
- cell.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FF4472C4' }
- }
- cell.font = {
- color: { argb: 'FFFFFFFF' },
- bold: true,
- size: 11
- }
- cell.alignment = {
- horizontal: 'center',
- vertical: 'middle'
- }
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- }
-
- // 필수 필드 표시
- if (cell.value && String(cell.value).includes('*')) {
- cell.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFE74C3C' }
- }
- }
- })
-
- // 샘플 데이터 추가
- const sampleData = projectType === "ship" ? [
- "SH-2024-001",
- "기본 설계 도면",
- "B3",
- "김철수",
- new Date("2024-01-15"),
- "선박 기본 설계 관련 문서",
- "", "", "", "", "", "" // B4 필드들
- ] : [
- "PL-2024-001",
- "공정 설계 도면",
- "B4",
- "이영희",
- new Date("2024-01-15"),
- "플랜트 공정 설계 관련 문서",
- "V-001", // 벤더문서번호
- "삼성엔지니어링", // 벤더명
- "SENG", // 벤더코드
- "C1", "D1", "DEG1", "DEPT1", "S1", "J1" // B4 필드들
- ]
-
- const sampleRow = worksheet.addRow(sampleData)
- sampleRow.eachCell((cell, colNumber) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- }
-
- // 날짜 형식 설정
- if (cell.value instanceof Date) {
- cell.numFmt = 'yyyy-mm-dd'
- }
- })
-
- // 컬럼 너비 자동 조정
- worksheet.columns.forEach((column, index) => {
- if (index < 6) {
- column.width = headers[index].length + 5
- } else {
- column.width = 12
- }
- })
-
- // 문서종류 드롭다운 설정
- const docTypeCol = headers.indexOf("문서종류*") + 1
- worksheet.dataValidations.add(`${String.fromCharCode(64 + docTypeCol)}2:${String.fromCharCode(64 + docTypeCol)}1000`, {
- type: 'list',
- allowBlank: false,
- formulae: ['"B3,B4,B5"']
- })
-
- // Plant 프로젝트의 경우 우선순위 드롭다운 추가
- if (projectType === "plant") {
- // 여기에 추가적인 드롭다운들을 설정할 수 있습니다
- }
-
- return workbook
-}
-
-// 스테이지 템플릿 생성
-export async function createStageTemplate() {
- const workbook = new ExcelJS.Workbook()
- const worksheet = workbook.addWorksheet("스테이지목록", {
- properties: { defaultColWidth: 15 }
- })
-
- const headers = [
- "문서번호*",
- "스테이지명*",
- "계획일",
- "우선순위",
- "담당자",
- "설명",
- "스테이지순서"
- ]
-
- // 헤더 행 추가 및 스타일링
- const headerRow = worksheet.addRow(headers)
- headerRow.eachCell((cell, colNumber) => {
- cell.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FF27AE60' }
- }
- cell.font = {
- color: { argb: 'FFFFFFFF' },
- bold: true,
- size: 11
- }
- cell.alignment = {
- horizontal: 'center',
- vertical: 'middle'
- }
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- }
-
- // 필수 필드 표시
- if (cell.value && String(cell.value).includes('*')) {
- cell.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFE74C3C' }
- }
- }
- })
-
- // 샘플 데이터 추가
- const sampleData = [
- [
- "SH-2024-001",
- "초기 설계 검토",
- new Date("2024-02-15"),
- "HIGH",
- "김철수",
- "초기 설계안 검토 및 승인",
- 0
- ],
- [
- "SH-2024-001",
- "상세 설계",
- new Date("2024-03-15"),
- "MEDIUM",
- "이영희",
- "상세 설계 작업 수행",
- 1
- ]
- ]
-
- sampleData.forEach(rowData => {
- const row = worksheet.addRow(rowData)
- row.eachCell((cell, colNumber) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- }
-
- // 날짜 형식 설정
- if (cell.value instanceof Date) {
- cell.numFmt = 'yyyy-mm-dd'
- }
- })
- })
-
- // 컬럼 너비 설정
- worksheet.columns = [
- { width: 15 }, // 문서번호
- { width: 20 }, // 스테이지명
- { width: 12 }, // 계획일
- { width: 10 }, // 우선순위
- { width: 15 }, // 담당자
- { width: 30 }, // 설명
- { width: 12 }, // 스테이지순서
- ]
-
- // 우선순위 드롭다운 설정
- worksheet.dataValidations.add('D2:D1000', {
- type: 'list',
- allowBlank: true,
- formulae: ['"HIGH,MEDIUM,LOW"']
- })
-
- return workbook
-}
-
-// 템플릿 다운로드 함수
-export async function downloadTemplate(type: "documents" | "stages", projectType: "ship" | "plant") {
- const workbook = await (type === "documents"
- ? createDocumentTemplate(projectType)
- : createStageTemplate())
-
- const filename = type === "documents"
- ? `문서_임포트_템플릿_${projectType}.xlsx`
- : `스테이지_임포트_템플릿.xlsx`
-
- // 브라우저에서 다운로드
- 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 = filename
- link.click()
-
- // 메모리 정리
- window.URL.revokeObjectURL(url)
-}
-
-// =============================================================================
-// 2. 엑셀 파일 읽기 및 파싱
-// =============================================================================
-
-// 엑셀 파일을 읽어서 JSON으로 변환
-export async function readExcelFile(file: File): Promise<any[]> {
- return new Promise((resolve, reject) => {
- const reader = new FileReader()
-
- reader.onload = async (e) => {
- try {
- const buffer = e.target?.result as ArrayBuffer
- const workbook = new ExcelJS.Workbook()
- await workbook.xlsx.load(buffer)
-
- const worksheet = workbook.getWorksheet(1) // 첫 번째 워크시트
- if (!worksheet) {
- throw new Error('워크시트를 찾을 수 없습니다')
- }
-
- const jsonData: any[] = []
-
- worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
- const rowData: any[] = []
- row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
- let value = cell.value
-
- // 날짜 처리
- if (cell.type === ExcelJS.ValueType.Date) {
- value = cell.value as Date
- }
- // 수식 결과값 처리
- else if (cell.type === ExcelJS.ValueType.Formula && cell.result) {
- value = cell.result
- }
- // 하이퍼링크 처리
- else if (cell.type === ExcelJS.ValueType.Hyperlink) {
- value = cell.value?.text || cell.value
- }
-
- rowData[colNumber - 1] = value || ""
- })
-
- jsonData.push(rowData)
- })
-
- resolve(jsonData)
- } catch (error) {
- reject(new Error('엑셀 파일을 읽는 중 오류가 발생했습니다: ' + error))
- }
- }
-
- reader.onerror = () => {
- reject(new Error('파일을 읽을 수 없습니다'))
- }
-
- reader.readAsArrayBuffer(file)
- })
-}
-
-// 문서 데이터 유효성 검사 및 변환
-export function validateDocumentRows(
- rawData: any[],
- contractId: number,
- projectType: "ship" | "plant"
-): { validData: CreateDocumentInput[], errors: any[] } {
- if (rawData.length < 2) {
- throw new Error('데이터가 없습니다. 최소 헤더와 1개 행이 필요합니다.')
- }
-
- const headers = rawData[0] as string[]
- const rows = rawData.slice(1)
-
- const validData: CreateDocumentInput[] = []
- const errors: any[] = []
-
- // 필수 헤더 검사
- const requiredHeaders = ["문서번호", "문서명", "문서종류"]
- const missingHeaders = requiredHeaders.filter(h =>
- !headers.some(header => header.includes(h.replace("*", "")))
- )
-
- if (missingHeaders.length > 0) {
- throw new Error(`필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`)
- }
-
- // 헤더 인덱스 매핑
- const headerMap: Record<string, number> = {}
- headers.forEach((header, index) => {
- const cleanHeader = header.replace("*", "").trim()
- headerMap[cleanHeader] = index
- })
-
- // 각 행 처리
- rows.forEach((row: any[], rowIndex) => {
- try {
- // 빈 행 스킵
- if (row.every(cell => !cell || String(cell).trim() === "")) {
- return
- }
-
- const rowData: any = {
- contractId,
- docNumber: String(row[headerMap["문서번호"]] || "").trim(),
- title: String(row[headerMap["문서명"]] || "").trim(),
- drawingKind: String(row[headerMap["문서종류"]] || "").trim(),
- pic: String(row[headerMap["PIC"]] || "").trim() || undefined,
- issuedDate: row[headerMap["발행일"]] ?
- formatExcelDate(row[headerMap["발행일"]]) : undefined,
- }
-
- // Plant 프로젝트 전용 필드
- if (projectType === "plant") {
- rowData.vendorDocNumber = String(row[headerMap["벤더문서번호"]] || "").trim() || undefined
- }
-
- // B4 전용 필드들
- const b4Fields = ["C구분", "D구분", "Degree구분", "부서구분", "S구분", "J구분"]
- const b4FieldMap = {
- "C구분": "cGbn",
- "D구분": "dGbn",
- "Degree구분": "degreeGbn",
- "부서구분": "deptGbn",
- "S구분": "sGbn",
- "J구분": "jGbn"
- }
-
- b4Fields.forEach(field => {
- if (headerMap[field] !== undefined) {
- const value = String(row[headerMap[field]] || "").trim()
- if (value) {
- rowData[b4FieldMap[field as keyof typeof b4FieldMap]] = value
- }
- }
- })
-
- // 유효성 검사
- const validatedData = excelDocumentRowSchema.parse({
- "문서번호": rowData.docNumber,
- "문서명": rowData.title,
- "문서종류": rowData.drawingKind,
- "벤더문서번호": rowData.vendorDocNumber,
- "PIC": rowData.pic,
- "발행일": rowData.issuedDate,
- "C구분": rowData.cGbn,
- "D구분": rowData.dGbn,
- "Degree구분": rowData.degreeGbn,
- "부서구분": rowData.deptGbn,
- "S구분": rowData.sGbn,
- "J구분": rowData.jGbn,
- })
-
- // CreateDocumentInput 형태로 변환
- const documentInput: CreateDocumentInput = {
- contractId,
- docNumber: validatedData["문서번호"],
- title: validatedData["문서명"],
- drawingKind: validatedData["문서종류"],
- vendorDocNumber: validatedData["벤더문서번호"],
- pic: validatedData["PIC"],
- issuedDate: validatedData["발행일"],
- cGbn: validatedData["C구분"],
- dGbn: validatedData["D구분"],
- degreeGbn: validatedData["Degree구분"],
- deptGbn: validatedData["부서구분"],
- sGbn: validatedData["S구분"],
- jGbn: validatedData["J구분"],
- }
-
- validData.push(documentInput)
-
- } catch (error) {
- errors.push({
- row: rowIndex + 2, // 엑셀 행 번호 (헤더 포함)
- message: error instanceof Error ? error.message : "알 수 없는 오류",
- data: row
- })
- }
- })
-
- return { validData, errors }
-}
-
-// 엑셀 날짜 형식 변환
-function formatExcelDate(value: any): string | undefined {
- if (!value) return undefined
-
- // ExcelJS에서 Date 객체로 처리된 경우
- if (value instanceof Date) {
- return value.toISOString().split('T')[0]
- }
-
- // 이미 문자열 날짜 형식인 경우
- if (typeof value === 'string') {
- const dateMatch = value.match(/^\d{4}-\d{2}-\d{2}$/)
- if (dateMatch) return value
-
- // 다른 형식 시도
- const date = new Date(value)
- if (!isNaN(date.getTime())) {
- return date.toISOString().split('T')[0]
- }
- }
-
- // 엑셀 시리얼 날짜인 경우
- if (typeof value === 'number') {
- // ExcelJS는 이미 Date 객체로 변환해주므로 이 경우는 드물지만
- // 1900년 1월 1일부터의 일수로 계산
- const excelEpoch = new Date(1900, 0, 1)
- const date = new Date(excelEpoch.getTime() + (value - 2) * 24 * 60 * 60 * 1000)
- if (!isNaN(date.getTime())) {
- return date.toISOString().split('T')[0]
- }
- }
-
- return undefined
-}
-
-// =============================================================================
-// 3. 데이터 익스포트
-// =============================================================================
-
-// 문서 데이터를 엑셀로 익스포트
-export function exportDocumentsToExcel(
- documents: StageDocumentsView[],
- projectType: "ship" | "plant"
-) {
- const headers = [
- "문서번호",
- "문서명",
- "문서종류",
- "PIC",
- "발행일",
- "현재스테이지",
- "스테이지상태",
- "계획일",
- "담당자",
- "우선순위",
- "진행률(%)",
- "완료스테이지",
- "전체스테이지",
- "지연여부",
- "남은일수",
- "생성일",
- "수정일"
- ]
-
- // Plant 프로젝트 전용 헤더 추가
- if (projectType === "plant") {
- headers.splice(3, 0, "벤더문서번호", "벤더명", "벤더코드")
- }
-
- const data = documents.map(doc => {
- const baseData = [
- doc.docNumber,
- doc.title,
- doc.drawingKind || "",
- doc.pic || "",
- doc.issuedDate || "",
- doc.currentStageName || "",
- getStatusText(doc.currentStageStatus || ""),
- doc.currentStagePlanDate || "",
- doc.currentStageAssigneeName || "",
- getPriorityText(doc.currentStagePriority || ""),
- doc.progressPercentage || 0,
- doc.completedStages || 0,
- doc.totalStages || 0,
- doc.isOverdue ? "예" : "아니오",
- doc.daysUntilDue || "",
- doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : "",
- doc.updatedAt ? new Date(doc.updatedAt).toLocaleDateString() : ""
- ]
-
- // Plant 프로젝트 데이터 추가
- if (projectType === "plant") {
- baseData.splice(3, 0,
- doc.vendorDocNumber || "",
- doc.vendorName || "",
- doc.vendorCode || ""
- )
- }
-
- return baseData
- })
-
- const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data])
-
- // 컬럼 너비 설정
- const colWidths = [
- { wch: 15 }, // 문서번호
- { wch: 30 }, // 문서명
- { wch: 10 }, // 문서종류
- ...(projectType === "plant" ? [
- { wch: 15 }, // 벤더문서번호
- { wch: 20 }, // 벤더명
- { wch: 10 }, // 벤더코드
- ] : []),
- { wch: 10 }, // PIC
- { wch: 12 }, // 발행일
- { wch: 15 }, // 현재스테이지
- { wch: 10 }, // 스테이지상태
- { wch: 12 }, // 계획일
- { wch: 10 }, // 담당자
- { wch: 8 }, // 우선순위
- { wch: 8 }, // 진행률
- { wch: 8 }, // 완료스테이지
- { wch: 8 }, // 전체스테이지
- { wch: 8 }, // 지연여부
- { wch: 8 }, // 남은일수
- { wch: 12 }, // 생성일
- { wch: 12 }, // 수정일
- ]
-
- worksheet['!cols'] = colWidths
-
- const workbook = XLSX.utils.book_new()
- XLSX.utils.book_append_sheet(workbook, worksheet, "문서목록")
-
- const filename = `문서목록_${new Date().toISOString().split('T')[0]}.xlsx`
- XLSX.writeFile(workbook, filename)
-}
-
-// 스테이지 상세 데이터를 엑셀로 익스포트
-export function exportStageDetailsToExcel(documents: StageDocumentsView[]) {
- const headers = [
- "문서번호",
- "문서명",
- "스테이지명",
- "스테이지상태",
- "스테이지순서",
- "계획일",
- "담당자",
- "우선순위",
- "설명",
- "노트",
- "알림일수"
- ]
-
- const data: any[] = []
-
- documents.forEach(doc => {
- if (doc.allStages && doc.allStages.length > 0) {
- doc.allStages.forEach(stage => {
- data.push([
- doc.docNumber,
- doc.title,
- stage.stageName,
- getStatusText(stage.stageStatus),
- stage.stageOrder,
- stage.planDate || "",
- stage.assigneeName || "",
- getPriorityText(stage.priority),
- stage.description || "",
- stage.notes || "",
- stage.reminderDays || ""
- ])
- })
- } else {
- // 스테이지가 없는 문서도 포함
- data.push([
- doc.docNumber,
- doc.title,
- "", "", "", "", "", "", "", "", ""
- ])
- }
- })
-
- const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data])
-
- // 컬럼 너비 설정
- worksheet['!cols'] = [
- { wch: 15 }, // 문서번호
- { wch: 30 }, // 문서명
- { wch: 20 }, // 스테이지명
- { wch: 12 }, // 스테이지상태
- { wch: 8 }, // 스테이지순서
- { wch: 12 }, // 계획일
- { wch: 10 }, // 담당자
- { wch: 8 }, // 우선순위
- { wch: 25 }, // 설명
- { wch: 25 }, // 노트
- { wch: 8 }, // 알림일수
- ]
-
- const workbook = XLSX.utils.book_new()
- XLSX.utils.book_append_sheet(workbook, worksheet, "스테이지상세")
-
- const filename = `스테이지상세_${new Date().toISOString().split('T')[0]}.xlsx`
- XLSX.writeFile(workbook, filename)
-}
-
-// =============================================================================
-// 4. 유틸리티 함수들
-// =============================================================================
-
-function getStatusText(status: string): string {
- switch (status) {
- case 'PLANNED': return '계획됨'
- case 'IN_PROGRESS': return '진행중'
- case 'SUBMITTED': return '제출됨'
- case 'UNDER_REVIEW': return '검토중'
- case 'APPROVED': return '승인됨'
- case 'REJECTED': return '반려됨'
- case 'COMPLETED': return '완료됨'
- default: return status
- }
-}
-
-function getPriorityText(priority: string): string {
- switch (priority) {
- case 'HIGH': return '높음'
- case 'MEDIUM': return '보통'
- case 'LOW': return '낮음'
- default: return priority
- }
-}
-
-// 파일 크기 검증
-export function validateFileSize(file: File, maxSizeMB: number = 10): boolean {
- const maxSizeBytes = maxSizeMB * 1024 * 1024
- return file.size <= maxSizeBytes
-}
-
-// 파일 확장자 검증
-export function validateFileExtension(file: File): boolean {
- const allowedExtensions = ['.xlsx', '.xls']
- const fileName = file.name.toLowerCase()
- return allowedExtensions.some(ext => fileName.endsWith(ext))
-}
-
-// ExcelJS 워크북의 유효성 검사
-export async function validateExcelWorkbook(file: File): Promise<{
- isValid: boolean
- error?: string
- worksheetCount?: number
- firstWorksheetName?: string
-}> {
- try {
- const buffer = await file.arrayBuffer()
- const workbook = new ExcelJS.Workbook()
- await workbook.xlsx.load(buffer)
-
- const worksheets = workbook.worksheets
- if (worksheets.length === 0) {
- return {
- isValid: false,
- error: '워크시트가 없는 파일입니다'
- }
- }
-
- const firstWorksheet = worksheets[0]
- if (firstWorksheet.rowCount < 2) {
- return {
- isValid: false,
- error: '데이터가 없습니다. 최소 헤더와 1개 행이 필요합니다'
- }
- }
-
- return {
- isValid: true,
- worksheetCount: worksheets.length,
- firstWorksheetName: firstWorksheet.name
- }
- } catch (error) {
- return {
- isValid: false,
- error: `파일을 읽을 수 없습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`
- }
- }
-}
-
-// 셀 값을 안전하게 문자열로 변환
-export function getCellValueAsString(cell: ExcelJS.Cell): string {
- if (!cell.value) return ""
-
- if (cell.value instanceof Date) {
- return cell.value.toISOString().split('T')[0]
- }
-
- if (typeof cell.value === 'object' && 'text' in cell.value) {
- return cell.value.text || ""
- }
-
- if (typeof cell.value === 'object' && 'result' in cell.value) {
- return String(cell.value.result || "")
- }
-
- return String(cell.value)
-}
-
-// 엑셀 컬럼 인덱스를 문자로 변환 (A, B, C, ... Z, AA, AB, ...)
-export 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
-} \ No newline at end of file
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
new file mode 100644
index 00000000..8dc85c51
--- /dev/null
+++ b/lib/vendor-document-list/plant/excel-import-stage copy 2.tsx
@@ -0,0 +1,899 @@
+"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
new file mode 100644
index 00000000..068383af
--- /dev/null
+++ b/lib/vendor-document-list/plant/excel-import-stage copy.tsx
@@ -0,0 +1,908 @@
+
+
+"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
new file mode 100644
index 00000000..8dc85c51
--- /dev/null
+++ b/lib/vendor-document-list/plant/excel-import-stage.tsx
@@ -0,0 +1,899 @@
+"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/upload/columns.tsx b/lib/vendor-document-list/plant/upload/columns.tsx
index c0f17afc..01fc61df 100644
--- a/lib/vendor-document-list/plant/upload/columns.tsx
+++ b/lib/vendor-document-list/plant/upload/columns.tsx
@@ -25,7 +25,8 @@ import {
CheckCircle2,
XCircle,
AlertCircle,
- Clock
+ Clock,
+ Download
} from "lucide-react"
interface GetColumnsProps {
@@ -360,6 +361,16 @@ export function getColumns({
</>
)}
+
+ {/* ✅ 커버 페이지 다운로드 */}
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "downloadCover" })}
+ className="gap-2"
+ >
+ <Download className="h-4 w-4" />
+ Download Cover Page
+ </DropdownMenuItem>
+
<DropdownMenuSeparator />
<DropdownMenuItem
diff --git a/lib/vendor-document-list/plant/upload/table.tsx b/lib/vendor-document-list/plant/upload/table.tsx
index 92507900..84b04092 100644
--- a/lib/vendor-document-list/plant/upload/table.tsx
+++ b/lib/vendor-document-list/plant/upload/table.tsx
@@ -20,6 +20,7 @@ import { ProjectFilter } from "./components/project-filter"
import { SingleUploadDialog } from "./components/single-upload-dialog"
import { HistoryDialog } from "./components/history-dialog"
import { ViewSubmissionDialog } from "./components/view-submission-dialog"
+import { toast } from "sonner"
interface StageSubmissionsTableProps {
promises: Promise<[
@@ -159,6 +160,30 @@ export function StageSubmissionsTable({ promises, selectedProjectId }: StageSubm
columnResizeMode: "onEnd",
})
+
+ React.useEffect(() => {
+ if (!rowAction) return;
+
+ const { type, row } = rowAction;
+
+ if (type === "downloadCover") {
+ // 2) 서버에서 생성 후 다운로드 (예: API 호출)
+ (async () => {
+ try {
+ const res = await fetch(`/api/stages/${row.original.stageId}/cover`, { method: "POST" });
+ if (!res.ok) throw new Error("failed");
+ const { fileUrl } = await res.json(); // 서버 응답: { fileUrl: string }
+ window.open(fileUrl, "_blank", "noopener,noreferrer");
+ } catch (e) {
+ toast.error("커버 페이지 생성에 실패했습니다.");
+ console.error(e);
+ } finally {
+ setRowAction(null);
+ }
+ })();
+ }
+ }, [rowAction, setRowAction]);
+
return (
<>
<DataTable table={table}>