diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-26 09:57:24 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-26 09:57:24 +0000 |
| commit | 8b23b471638a155fd1bfa3a8c853b26d9315b272 (patch) | |
| tree | 47353e9dd342011cb2f1dcd24b09661707a8421b /lib/vendor-document-list/plant | |
| parent | d62368d2b68d73da895977e60a18f9b1286b0545 (diff) | |
(대표님) 권한관리, 문서업로드, rfq첨부, SWP문서룰 등
(최겸) 입찰
Diffstat (limited to 'lib/vendor-document-list/plant')
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}> |
