diff options
Diffstat (limited to 'lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx')
| -rw-r--r-- | lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx | 313 |
1 files changed, 229 insertions, 84 deletions
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx index 7bffdac9..7d828a7e 100644 --- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx @@ -7,7 +7,6 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { formatDate } from "@/lib/utils"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { BasicContractSignViewer } from "@/lib/basic-contract/viewer/basic-contract-sign-viewer"; import type { WebViewerInstance } from "@pdftron/webviewer"; import type { BasicContractView } from "@/db/schema"; import { @@ -19,45 +18,82 @@ import { FileText, User, AlertCircle, - Calendar + Calendar, + Loader2 } from "lucide-react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { useRouter } from "next/navigation" +import { BasicContractSignViewer } from "../viewer/basic-contract-sign-viewer"; +import { getVendorAttachments } from "../service"; -// 수정된 props 인터페이스 interface BasicContractSignDialogProps { contracts: BasicContractView[]; onSuccess?: () => void; + hasSelectedRows?: boolean; + t: (key: string) => string; } -export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractSignDialogProps) { +export function BasicContractSignDialog({ + contracts, + onSuccess, + hasSelectedRows = false, + t +}: BasicContractSignDialogProps) { const [open, setOpen] = React.useState(false); const [selectedContract, setSelectedContract] = React.useState<BasicContractView | null>(null); const [instance, setInstance] = React.useState<null | WebViewerInstance>(null); const [searchTerm, setSearchTerm] = React.useState(""); const [isSubmitting, setIsSubmitting] = React.useState(false); + + // 추가된 state들 + const [additionalFiles, setAdditionalFiles] = React.useState<any[]>([]); + const [isLoadingAttachments, setIsLoadingAttachments] = React.useState(false); + const router = useRouter() + console.log(selectedContract,"selectedContract") + console.log(additionalFiles,"additionalFiles") + + // 버튼 비활성화 조건 + const isButtonDisabled = !hasSelectedRows || contracts.length === 0; + + // 비활성화 이유 텍스트 + const getDisabledReason = () => { + if (!hasSelectedRows) { + return t("basicContracts.toolbar.selectRows"); + } + if (contracts.length === 0) { + return t("basicContracts.toolbar.noPendingContracts"); + } + return ""; + }; + // 다이얼로그 열기/닫기 핸들러 const handleOpenChange = (isOpen: boolean) => { setOpen(isOpen); - // 다이얼로그가 열릴 때 첫 번째 계약서 자동 선택 - if (isOpen && contracts.length > 0 && !selectedContract) { - setSelectedContract(contracts[0]); - } - if (!isOpen) { + // 다이얼로그 닫을 때 상태 초기화 setSelectedContract(null); setSearchTerm(""); + setAdditionalFiles([]); // 추가 파일 상태 초기화 + // WebViewer 인스턴스 정리 + if (instance) { + try { + instance.UI.dispose(); + } catch (error) { + console.log("WebViewer dispose error:", error); + } + setInstance(null); + } } }; // 계약서 선택 핸들러 const handleSelectContract = (contract: BasicContractView) => { + console.log("계약서 선택:", contract.id, contract.templateName); setSelectedContract(contract); }; @@ -79,6 +115,40 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS } }, [open, contracts, selectedContract]); + // 추가 파일 가져오기 useEffect + React.useEffect(() => { + const fetchAdditionalFiles = async () => { + if (!selectedContract) { + setAdditionalFiles([]); + return; + } + + // "비밀유지 계약서"인 경우에만 추가 파일 가져오기 + if (selectedContract.templateName === "비밀유지 계약서") { + setIsLoadingAttachments(true); + try { + const result = await getVendorAttachments(selectedContract.vendorId); + if (result.success) { + setAdditionalFiles(result.data); + console.log("추가 파일 로드됨:", result.data); + } else { + console.error("Failed to fetch attachments:", result.error); + setAdditionalFiles([]); + } + } catch (error) { + console.error("Error fetching attachments:", error); + setAdditionalFiles([]); + } finally { + setIsLoadingAttachments(false); + } + } else { + setAdditionalFiles([]); + } + }; + + fetchAdditionalFiles(); + }, [selectedContract]); + // 서명 완료 핸들러 const completeSign = async () => { if (!instance || !selectedContract) return; @@ -89,29 +159,57 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS const doc = documentViewer.getDocument(); const xfdfString = await annotationManager.exportAnnotations(); + // 폼 필드 데이터 수집 + const fieldManager = annotationManager.getFieldManager(); + const fields = fieldManager.getFields(); + const formData: any = {}; + fields.forEach((field: any) => { + formData[field.name] = field.value; + }); + const data = await doc.getFileData({ xfdfString, downloadType: "pdf", }); // FormData 생성 및 파일 추가 - const formData = new FormData(); - formData.append('file', new Blob([data], { type: 'application/pdf' })); - formData.append('tableRowId', selectedContract.id.toString()); - formData.append('templateName', selectedContract.signedFileName || ''); + const submitFormData = new FormData(); + submitFormData.append('file', new Blob([data], { type: 'application/pdf' })); + submitFormData.append('tableRowId', selectedContract.id.toString()); + submitFormData.append('templateName', selectedContract.signedFileName || ''); + + // 폼 필드 데이터 추가 + if (Object.keys(formData).length > 0) { + submitFormData.append('formData', JSON.stringify(formData)); + } + + // 준법 템플릿인 경우 필수 필드 검증 + if (selectedContract.templateName?.includes('준법')) { + const requiredFields = ['compliance_agreement', 'legal_review', 'risk_assessment']; + const missingFields = requiredFields.filter(field => !formData[field]); + + if (missingFields.length > 0) { + toast.error("필수 준법 항목이 누락되었습니다.", { + description: `다음 항목을 완료해주세요: ${missingFields.join(', ')}`, + icon: <AlertCircle className="h-5 w-5 text-red-500" /> + }); + setIsSubmitting(false); + return; + } + } // API 호출 const response = await fetch('/api/upload/signed-contract', { method: 'POST', - body: formData, + body: submitFormData, next: { tags: ["basicContractView-vendor"] }, }); const result = await response.json(); if (result.result) { - toast.success("서명이 성공적으로 완료되었습니다.", { - description: "문서가 성공적으로 처리되었습니다.", + toast.success(t("basicContracts.messages.signSuccess"), { + description: t("basicContracts.messages.documentProcessed"), icon: <CheckCircle2 className="h-5 w-5 text-green-500" /> }); router.refresh(); @@ -120,22 +218,19 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS onSuccess(); } } else { - toast.error("서명 처리 중 오류가 발생했습니다.", { + toast.error(t("basicContracts.messages.signError"), { description: result.error, icon: <AlertCircle className="h-5 w-5 text-red-500" /> }); } } catch (error) { console.error("서명 완료 중 오류:", error); - toast.error("서명 처리 중 오류가 발생했습니다."); + toast.error(t("basicContracts.messages.signError")); } finally { setIsSubmitting(false); } }; - // 서명 대기중(PENDING) 계약서가 있는지 확인 - const hasPendingContracts = contracts.length > 0; - return ( <> {/* 서명 버튼 */} @@ -143,62 +238,67 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS variant="outline" size="sm" onClick={() => setOpen(true)} - disabled={!hasPendingContracts} - className="gap-2 transition-all hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200" + disabled={isButtonDisabled} + className="gap-2 transition-all hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 disabled:opacity-50 disabled:cursor-not-allowed" > - <Upload className="size-4 text-blue-500" aria-hidden="true" /> + <Upload + className={`size-4 ${isButtonDisabled ? 'text-gray-400' : 'text-blue-500'}`} + aria-hidden="true" + /> <span className="hidden sm:inline flex items-center"> - 서명하기 - {contracts.length > 0 && ( + {t("basicContracts.toolbar.sign")} + {contracts.length > 0 && !isButtonDisabled && ( <Badge variant="secondary" className="ml-2 bg-blue-100 text-blue-700 hover:bg-blue-200"> {contracts.length} </Badge> )} + {isButtonDisabled && ( + <span className="ml-2 text-xs text-gray-400"> + ({getDisabledReason()}) + </span> + )} </span> </Button> - {/* 서명 다이얼로그 - 고정 높이 유지 */} + {/* 서명 다이얼로그 - 레이아웃 개선 */} <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="max-w-5xl h-[650px] w-[90vw] p-0 overflow-hidden rounded-lg shadow-lg border border-gray-200"> - <DialogHeader className="p-6 bg-gradient-to-r from-blue-50 to-purple-50 border-b"> + <DialogContent className="max-w-7xl w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden" style={{width:'95vw', maxWidth:'95vw'}}> + {/* 고정 헤더 */} + <DialogHeader className="px-6 py-4 bg-gradient-to-r from-blue-50 to-purple-50 border-b flex-shrink-0"> <DialogTitle className="text-xl font-bold flex items-center text-gray-800"> <FileSignature className="mr-2 h-5 w-5 text-blue-500" /> - 기본계약서 및 관련문서 서명 + {t("basicContracts.dialog.title")} + {/* 추가 파일 로딩 표시 */} + {isLoadingAttachments && ( + <Loader2 className="ml-2 h-4 w-4 animate-spin text-blue-500" /> + )} </DialogTitle> </DialogHeader> - <div className="grid grid-cols-2 h-[calc(100%-4rem)] overflow-hidden"> - {/* 왼쪽 영역 - 계약서 목록 */} - <div className="col-span-1 border-r border-gray-200 bg-gray-50"> - <div className="p-4 border-b"> - <div className="relative mb-10"> - <div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none"> - <Search className="h-4 w-8 text-gray-400" /> + {/* 메인 컨텐츠 영역 - Flexbox 사용 */} + <div className="flex flex-1 min-h-0 overflow-hidden"> + {/* 왼쪽 영역 - 계약서 목록 (고정 너비) */} + <div className="w-80 border-r border-gray-200 bg-gray-50 flex flex-col flex-shrink-0"> + <div className="p-3 border-b flex-shrink-0"> + <div className="relative"> + <div className="absolute inset-y-0 left-2 flex items-center pointer-events-none"> + <Search className="h-4 w-4 text-gray-400" /> </div> <Input - placeholder="문서명 또는 요청자 검색" - className="bg-white" - style={{paddingLeft:25}} + placeholder={t("basicContracts.dialog.searchPlaceholder")} + className="bg-white pl-8 text-sm" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> </div> - <Tabs defaultValue="all" className="w-full"> - <TabsList className="w-full"> - <TabsTrigger value="all" className="flex-1">전체 ({contracts.length})</TabsTrigger> - <TabsTrigger value="contracts" className="flex-1">계약서</TabsTrigger> - <TabsTrigger value="docs" className="flex-1">관련문서</TabsTrigger> - </TabsList> - </Tabs> </div> - <ScrollArea className="h-[calc(100%-6rem)]"> - <div className="p-3"> + <ScrollArea className="flex-1"> + <div className="p-2"> {filteredContracts.length === 0 ? ( - <div className="flex flex-col items-center justify-center h-40 text-center"> - <FileText className="h-12 w-12 text-gray-300 mb-2" /> - <p className="text-gray-500 font-medium">서명 요청된 문서가 없습니다.</p> - <p className="text-gray-400 text-sm mt-1">나중에 다시 확인해주세요.</p> + <div className="flex flex-col items-center justify-center h-32 text-center"> + <FileText className="h-8 w-8 text-gray-300 mb-2" /> + <p className="text-gray-500 text-sm font-medium">{t("basicContracts.dialog.noDocuments")}</p> </div> ) : ( <div className="space-y-2"> @@ -207,30 +307,38 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS key={contract.id} variant="outline" className={cn( - "w-full justify-start text-left h-auto p-3 bg-white hover:bg-blue-50 transition-colors", + "w-full justify-start text-left h-auto p-2 bg-white hover:bg-blue-50 transition-colors", "border border-gray-200 hover:border-blue-200 rounded-md", selectedContract?.id === contract.id && "border-blue-500 bg-blue-50 shadow-sm" )} onClick={() => handleSelectContract(contract)} > - <div className="flex flex-col w-full"> + <div className="flex flex-col w-full space-y-1"> + {/* 첫 번째 줄: 제목 + 상태 */} <div className="flex items-center justify-between w-full"> - <span className="font-semibold truncate text-gray-800 flex items-center"> - <FileText className="h-4 w-4 mr-2 text-blue-500" /> - {contract.templateName || '문서'} + <span className="font-medium text-xs truncate text-gray-800 flex items-center min-w-0"> + <FileText className="h-3 w-3 mr-1 text-blue-500 flex-shrink-0" /> + <span className="truncate">{contract.templateName || t("basicContracts.dialog.document")}</span> + {/* 비밀유지 계약서인 경우 표시 */} + {contract.templateName === "비밀유지 계약서" && ( + <Badge variant="outline" className="ml-1 bg-green-50 text-green-700 border-green-200 text-xs"> + NDA + </Badge> + )} </span> - <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200"> - 대기중 + <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 text-xs ml-2 flex-shrink-0"> + {t("basicContracts.statusValues.PENDING")} </Badge> </div> - <Separator className="my-2 bg-gray-100" /> - <div className="grid grid-cols-2 gap-1 mt-1 text-xs text-gray-500"> - <div className="flex items-center"> - <User className="h-3 w-3 mr-1" /> - <span className="truncate">{contract.requestedByName || '알 수 없음'}</span> + + {/* 두 번째 줄: 사용자 + 날짜 */} + <div className="flex items-center justify-between text-xs text-gray-500"> + <div className="flex items-center min-w-0"> + <User className="h-3 w-3 mr-1 flex-shrink-0" /> + <span className="truncate">{contract.requestedByName || t("basicContracts.dialog.unknown")}</span> </div> - <div className="flex items-center justify-end"> - <Calendar className="h-3 w-3 mr-1" /> + <div className="flex items-center ml-2 flex-shrink-0"> + <Calendar className="h-3 w-3 mr-1 flex-shrink-0" /> <span>{formatDate(contract.createdAt)}</span> </div> </div> @@ -243,19 +351,32 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS </ScrollArea> </div> - {/* 오른쪽 영역 - 문서 뷰어 */} - <div className="col-span-1 bg-white flex flex-col h-full"> + {/* 오른쪽 영역 - 문서 뷰어 (확장 가능) */} + <div className="flex-1 bg-white flex flex-col min-w-0"> {selectedContract ? ( <> - <div className="p-3 border-b bg-gray-50"> + {/* 뷰어 헤더 */} + <div className="p-4 border-b bg-gray-50 flex-shrink-0"> <h3 className="font-semibold text-gray-800 flex items-center"> <FileText className="h-4 w-4 mr-2 text-blue-500" /> - {selectedContract.templateName || '문서'} + {selectedContract.templateName || t("basicContracts.dialog.document")} + {/* 준법 템플릿 표시 */} + {selectedContract.templateName?.includes('준법') && ( + <Badge variant="outline" className="ml-2 bg-amber-50 text-amber-700 border-amber-200"> + 준법 서류 + </Badge> + )} + {/* 비밀유지 계약서인 경우 추가 파일 수 표시 */} + {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && ( + <Badge variant="outline" className="ml-2 bg-blue-50 text-blue-700 border-blue-200"> + 첨부파일 {additionalFiles.length}개 + </Badge> + )} </h3> - <div className="flex justify-between items-center mt-1 text-xs text-gray-500"> + <div className="flex justify-between items-center mt-2 text-sm text-gray-500"> <span className="flex items-center"> <User className="h-3 w-3 mr-1" /> - 요청자: {selectedContract.requestedByName || '알 수 없음'} + {t("basicContracts.dialog.requester")}: {selectedContract.requestedByName || t("basicContracts.dialog.unknown")} </span> <span className="flex items-center"> <Clock className="h-3 w-3 mr-1" /> @@ -263,19 +384,43 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS </span> </div> </div> - <div className="flex-grow overflow-hidden border-b"> + + {/* 뷰어 영역 - 남은 공간 모두 사용 */} + <div className="flex-1 min-h-0 overflow-hidden"> <BasicContractSignViewer + key={selectedContract.id} // key 추가로 컴포넌트 재생성 강제 contractId={selectedContract.id} filePath={selectedContract.signedFilePath || undefined} + templateName={selectedContract.templateName || ""} + additionalFiles={additionalFiles} // 추가 파일 전달 instance={instance} setInstance={setInstance} + t={t} /> </div> - <div className="p-3 flex justify-between items-center bg-gray-50"> - <p className="text-sm text-gray-600"> - <AlertCircle className="h-4 w-4 text-yellow-500 inline mr-1" /> - 서명 후에는 변경할 수 없습니다. - </p> + + {/* 고정 푸터 */} + <div className="p-4 flex justify-between items-center bg-gray-50 border-t flex-shrink-0"> + <div className="flex items-center space-x-4"> + <p className="text-sm text-gray-600 flex items-center"> + <AlertCircle className="h-4 w-4 text-yellow-500 mr-1" /> + {t("basicContracts.dialog.signWarning")} + </p> + {/* 준법 템플릿인 경우 추가 안내 */} + {selectedContract.templateName?.includes('준법') && ( + <p className="text-xs text-amber-600 flex items-center"> + <AlertCircle className="h-3 w-3 text-amber-500 mr-1" /> + 모든 준법 항목을 체크해주세요 + </p> + )} + {/* 비밀유지 계약서인 경우 추가 안내 */} + {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && ( + <p className="text-xs text-blue-600 flex items-center"> + <FileText className="h-3 w-3 text-blue-500 mr-1" /> + 첨부 서류도 확인해주세요 + </p> + )} + </div> <Button className="gap-2 bg-blue-600 hover:bg-blue-700 transition-colors" onClick={completeSign} @@ -287,12 +432,12 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg> - 처리 중... + {t("basicContracts.dialog.processing")} </> ) : ( <> <FileSignature className="h-4 w-4" /> - 서명 완료 + {t("basicContracts.dialog.completeSign")} </> )} </Button> @@ -303,9 +448,9 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS <div className="bg-blue-50 p-6 rounded-full mb-4"> <FileSignature className="h-12 w-12 text-blue-500" /> </div> - <h3 className="text-xl font-medium text-gray-800 mb-2">문서를 선택해주세요</h3> + <h3 className="text-xl font-medium text-gray-800 mb-2">{t("basicContracts.dialog.selectDocument")}</h3> <p className="text-gray-500 max-w-md"> - 왼쪽 목록에서 서명할 문서를 선택하면 여기에 문서 내용이 표시됩니다. + {t("basicContracts.dialog.selectDocumentDescription")} </p> </div> )} |
