diff options
Diffstat (limited to 'lib/basic-contract')
| -rw-r--r-- | lib/basic-contract/gtc-vendor/clause-table.tsx | 5 | ||||
| -rw-r--r-- | lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx | 9 | ||||
| -rw-r--r-- | lib/basic-contract/gtc-vendor/preview-document-dialog.tsx | 331 | ||||
| -rw-r--r-- | lib/basic-contract/service.ts | 88 |
4 files changed, 390 insertions, 43 deletions
diff --git a/lib/basic-contract/gtc-vendor/clause-table.tsx b/lib/basic-contract/gtc-vendor/clause-table.tsx index a9230cd4..88b1f45c 100644 --- a/lib/basic-contract/gtc-vendor/clause-table.tsx +++ b/lib/basic-contract/gtc-vendor/clause-table.tsx @@ -30,6 +30,8 @@ interface GtcClausesTableProps { Awaited<ReturnType<typeof getGtcClauses>>, Awaited<ReturnType<typeof getUsersForFilter>>, Awaited<ReturnType<typeof getVendorClausesForDocument>>, + Vendor | null, // vendor 데이터 추가 + ] > , @@ -46,7 +48,7 @@ export function GtcClausesVendorTable({ vendorId, vendorName }: GtcClausesTableProps) { - const [{ data, pageCount }, users, vendorData] = React.use(promises) + const [{ data, pageCount }, users, vendorData, vendor] = React.use(promises) const [rowAction, setRowAction] = @@ -195,6 +197,7 @@ export function GtcClausesVendorTable({ table={table} documentId={documentId} document={document} + vendor={vendor} /> </DataTableAdvancedToolbar> </DataTable> diff --git a/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx b/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx index 3a0fbdb6..f0cebe5f 100644 --- a/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx +++ b/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx @@ -37,12 +37,14 @@ import { exportFullDataToExcel, type ExcelColumnDef } from "@/lib/export" import { getAllGtcClausesForExport, importGtcClausesFromExcel } from "@/lib/gtc-contract/service" import { ImportExcelDialog } from "./import-excel-dialog" import { toast } from "@/hooks/use-toast" +import { Vendor } from "@/db/schema" interface GtcClausesTableToolbarActionsProps { table: Table<GtcClauseTreeView> documentId: number document: any currentUserId?: number // 현재 사용자 ID 추가 + vendor:Vendor } // GTC 조항을 위한 Excel 컬럼 정의 (실용적으로 간소화) @@ -101,7 +103,7 @@ export function GtcClausesTableToolbarActions({ table, documentId, document, - currentUserId = 1, // 기본값 설정 (실제로는 auth에서 가져와야 함) + vendor }: GtcClausesTableToolbarActionsProps) { const [showCreateDialog, setShowCreateDialog] = React.useState(false) const [showReorderDialog, setShowReorderDialog] = React.useState(false) @@ -188,7 +190,7 @@ export function GtcClausesTableToolbarActions({ // Excel 데이터 가져오기 처리 const handleImportExcelData = async (data: Partial<GtcClauseTreeView>[]) => { try { - const result = await importGtcClausesFromExcel(documentId, data, currentUserId) + const result = await importGtcClausesFromExcel(documentId, data) if (result.success) { toast({ @@ -340,7 +342,8 @@ export function GtcClausesTableToolbarActions({ open={showPreviewDialog} onOpenChange={setShowPreviewDialog} clauses={previewClauses} - document={document} + contractDocument={document} + vendor={vendor} onExport={() => { console.log("Export from preview dialog") }} diff --git a/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx b/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx index 78ddc7f7..c017b8be 100644 --- a/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx +++ b/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx @@ -5,38 +5,62 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Separator } from "@/components/ui/separator" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" import { Eye, Download, + Save, + Upload, Loader2, FileText, RefreshCw, Settings, - AlertCircle + AlertCircle, + CheckCircle } from "lucide-react" import { toast } from "sonner" import { type GtcClauseTreeView } from "@/db/schema/gtc" import { ClausePreviewViewer } from "./clause-preview-viewer" +import { saveGtcDocumentAction } from "../service" + +interface Vendor { + vendorName: string + address: string + representativeName: string + taxId: string + phone: string +} interface PreviewDocumentDialogProps extends React.ComponentPropsWithRef<typeof Dialog> { clauses: GtcClauseTreeView[] - document: any + contractDocument: any + vendor: Vendor onExport?: () => void } export function PreviewDocumentDialog({ clauses, - document, + contractDocument, + vendor, onExport, ...props }: PreviewDocumentDialogProps) { + const [isGenerating, setIsGenerating] = React.useState(false) + const [isSaving, setIsSaving] = React.useState(false) + const [isConverting, setIsConverting] = React.useState(false) const [documentGenerated, setDocumentGenerated] = React.useState(false) const [viewerInstance, setViewerInstance] = React.useState<any>(null) const [hasError, setHasError] = React.useState(false) + + // 파일 업로드 관련 상태 + const [selectedFile, setSelectedFile] = React.useState<File | null>(null) + const [convertedPdf, setConvertedPdf] = React.useState<Uint8Array | null>(null) + const fileInputRef = React.useRef<HTMLInputElement>(null) // 조항 통계 계산 const stats = React.useMemo(() => { @@ -58,8 +82,6 @@ export function PreviewDocumentDialog({ setDocumentGenerated(false) try { - // 실제로는 ClausePreviewViewer에서 문서 생성을 처리하므로 - // 여기서는 상태만 관리 console.log("🚀 문서 미리보기 생성 시작") // ClausePreviewViewer가 완전히 로드될 때까지 기다림 @@ -78,12 +100,182 @@ export function PreviewDocumentDialog({ } } + // 파일 선택 핸들러 + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (!file) return + + // Word 파일만 허용 + const allowedTypes = [ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx + 'application/msword' // .doc + ] + + if (!allowedTypes.includes(file.type)) { + toast.error("Word 파일(.doc, .docx)만 업로드할 수 있습니다.") + return + } + + if (file.size > 50 * 1024 * 1024) { // 50MB 제한 + toast.error("파일 크기는 50MB 이하여야 합니다.") + return + } + + setSelectedFile(file) + setConvertedPdf(null) // 이전 변환 결과 초기화 + toast.success(`파일이 선택되었습니다: ${file.name}`) + } + + // PDF 변환 함수 + const handleConvertToPdf = async () => { + if (!selectedFile) { + toast.error("먼저 Word 파일을 선택해주세요.") + return + } + + // 브라우저 환경 체크 + if (typeof window === 'undefined' || typeof document === 'undefined') { + toast.error("브라우저 환경에서만 PDF 변환이 가능합니다.") + return + } + + setIsConverting(true) + + try { + console.log("🔄 PDF 변환 시작:", selectedFile.name) + + // PDFTron WebViewer 동적 import + const { default: WebViewer } = await import("@pdftron/webviewer") + + // 임시 WebViewer 인스턴스 생성 (화면에 표시하지 않음) + const tempDiv = document.createElement('div') + tempDiv.style.display = 'none' + tempDiv.style.position = 'absolute' + tempDiv.style.top = '-9999px' + tempDiv.style.left = '-9999px' + tempDiv.style.width = '1px' + tempDiv.style.height = '1px' + document.body.appendChild(tempDiv) + + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + }, + tempDiv + ) + + try { + // WebViewer 초기화 대기 + await new Promise(resolve => setTimeout(resolve, 1000)) + + const { Core } = instance + const { createDocument } = Core + + const templateData = { + company_name: vendor.vendorName || '협력업체명', + company_address: vendor.address || '주소', + representative_name: vendor.representativeName || '대표자명', + signature_date: new Date().toLocaleDateString('ko-KR'), + tax_id: vendor.taxId || '사업자번호', + phone_number: vendor.phone || '전화번호', + } + + const templateDoc = await createDocument(selectedFile, { + filename: selectedFile.name|| 'template.docx', + extension: 'docx', + }) + + await templateDoc.applyTemplateValues(templateData) + + + // 문서 로드 완료 대기 + await new Promise(resolve => setTimeout(resolve, 3000)) + + // PDF로 변환 - 더 안전한 방식 + const fileData = await templateDoc.getFileData() + const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' }) + + console.log("✅ PDF 변환 완료:", pdfBuffer.byteLength, "bytes") + setConvertedPdf(new Uint8Array(pdfBuffer)) + toast.success("PDF 변환이 완료되었습니다.") + + } finally { + // 임시 WebViewer 정리 + try { + instance.UI.dispose() + } catch (disposeError) { + console.warn("WebViewer dispose 오류:", disposeError) + } + + try { + if (tempDiv && tempDiv.parentNode) { + document.body.removeChild(tempDiv) + } + } catch (removeError) { + console.warn("임시 div 제거 오류:", removeError) + } + } + + } catch (error) { + console.error("❌ PDF 변환 실패:", error) + toast.error(`PDF 변환 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } finally { + setIsConverting(false) + } + } + + // 문서 저장 함수 + const handleSaveDocument = async () => { + if (!convertedPdf || !selectedFile) { + toast.error("먼저 파일을 업로드하고 PDF로 변환해주세요.") + return + } + + setIsSaving(true) + + try { + console.log("💾 문서 저장 시작") + + const result = await saveGtcDocumentAction({ + documentId: contractDocument.id, + pdfBuffer: convertedPdf, + originalFileName: selectedFile.name, + vendor + }) + + if (result.success) { + toast.success(`문서가 성공적으로 저장되었습니다.`) + console.log("✅ 문서 저장 완료:", { + fileName: result.fileName, + filePath: result.filePath, + fileSize: result.fileSize + }) + + // 저장 완료 후 상태 초기화 + setSelectedFile(null) + setConvertedPdf(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } else { + throw new Error(result.error || "문서 저장에 실패했습니다.") + } + } catch (error) { + console.error("❌ 문서 저장 실패:", error) + toast.error(`문서 저장 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } finally { + setIsSaving(false) + } + } + const handleExportDocument = () => { if (viewerInstance) { try { - // PDFTron의 다운로드 기능 실행 viewerInstance.UI.downloadPdf({ - filename: `${document?.title || 'GTC계약서'}_미리보기.pdf` + filename: `${contractDocument?.title || 'GTC계약서'}_미리보기.pdf` }) toast.success("PDF 다운로드가 시작됩니다.") } catch (error) { @@ -119,7 +311,7 @@ export function PreviewDocumentDialog({ if (props.open && !documentGenerated && !isGenerating && !hasError) { const timer = setTimeout(() => { handleGeneratePreview() - }, 300) // 다이얼로그 애니메이션 후 시작 + }, 300) return () => clearTimeout(timer) } @@ -130,8 +322,15 @@ export function PreviewDocumentDialog({ if (!props.open) { setDocumentGenerated(false) setIsGenerating(false) + setIsSaving(false) + setIsConverting(false) setHasError(false) setViewerInstance(null) + setSelectedFile(null) + setConvertedPdf(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } } }, [props.open]) @@ -141,20 +340,23 @@ export function PreviewDocumentDialog({ <DialogHeader className="flex-shrink-0"> <DialogTitle className="flex items-center gap-2"> <Eye className="h-5 w-5" /> - 문서 미리보기 + 문서 미리보기 및 저장 </DialogTitle> <DialogDescription> - 현재 조항들을 기반으로 생성된 문서를 미리보기합니다. + 조항 기반 미리보기를 확인하고, Word 파일을 업로드하여 최종 문서를 저장하세요. </DialogDescription> </DialogHeader> {/* 문서 정보 및 통계 */} - <div className="flex-shrink-0 p-4 bg-muted/30 rounded-lg"> - <div className="flex items-center justify-between mb-3"> + <div className="flex-shrink-0 p-4 bg-muted/30 rounded-lg space-y-4"> + <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> <FileText className="h-4 w-4" /> - <span className="font-medium">{document?.title || 'GTC 계약서'}</span> + <span className="font-medium">{contractDocument?.title || 'GTC 계약서'}</span> <Badge variant="outline">{stats.total}개 조항</Badge> + {vendor && ( + <Badge variant="secondary">{vendor.vendorName}</Badge> + )} {hasError && ( <Badge variant="destructive" className="gap-1"> <AlertCircle className="h-3 w-3" /> @@ -164,33 +366,22 @@ export function PreviewDocumentDialog({ </div> <div className="flex items-center gap-2"> {documentGenerated && !hasError && ( - <> - <Button - variant="outline" - size="sm" - onClick={handleRegenerateDocument} - disabled={isGenerating} - > - <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> - 재생성 - </Button> - {/* <Button - variant="outline" - size="sm" - onClick={handleExportDocument} - disabled={!viewerInstance} - > - <Download className="mr-2 h-3 w-3" /> - PDF 다운로드 - </Button> */} - </> + <Button + variant="outline" + size="sm" + onClick={handleRegenerateDocument} + disabled={isGenerating || isSaving || isConverting} + > + <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> + 재생성 + </Button> )} {hasError && ( <Button variant="default" size="sm" onClick={handleRegenerateDocument} - disabled={isGenerating} + disabled={isGenerating || isSaving || isConverting} > <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> 다시 시도 @@ -199,7 +390,71 @@ export function PreviewDocumentDialog({ </div> </div> - <div className="grid grid-cols-4 gap-4 text-sm"> + {/* 파일 업로드 섹션 */} + <div className="border-t pt-4"> + <div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> + <div className="space-y-2"> + <Label htmlFor="file-upload">1. Word 파일 업로드</Label> + <div className="flex gap-2"> + <Input + ref={fileInputRef} + id="file-upload" + type="file" + accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document" + onChange={handleFileSelect} + disabled={isConverting || isSaving} + className="flex-1" + /> + {selectedFile && ( + <CheckCircle className="h-8 w-8 text-green-500 flex-shrink-0" /> + )} + </div> + {selectedFile && ( + <p className="text-sm text-muted-foreground"> + 선택됨: {selectedFile.name} ({(selectedFile.size / (1024 * 1024)).toFixed(2)}MB) + </p> + )} + </div> + + <div className="space-y-2"> + <Label>2. PDF 변환</Label> + <Button + onClick={handleConvertToPdf} + disabled={!selectedFile || isConverting || isSaving} + className="w-full" + variant={convertedPdf ? "outline" : "default"} + > + {isConverting ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : convertedPdf ? ( + <CheckCircle className="mr-2 h-4 w-4" /> + ) : ( + <RefreshCw className="mr-2 h-4 w-4" /> + )} + {isConverting ? "변환 중..." : convertedPdf ? "변환 완료" : "PDF 변환"} + </Button> + </div> + + <div className="space-y-2"> + <Label>3. 문서 저장</Label> + <Button + onClick={handleSaveDocument} + disabled={!convertedPdf || isSaving || isConverting} + className="w-full" + > + {isSaving ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Save className="mr-2 h-4 w-4" /> + )} + {isSaving ? "저장 중..." : "문서 저장"} + </Button> + </div> + </div> + </div> + + {/* 통계 정보 */} + <div className="grid grid-cols-4 gap-4 text-sm border-t pt-4"> <div className="text-center p-2 bg-background rounded"> <div className="font-medium text-lg">{stats.total}</div> <div className="text-muted-foreground">총 조항</div> @@ -241,7 +496,7 @@ export function PreviewDocumentDialog({ <p className="text-sm text-muted-foreground mb-4 text-center max-w-md"> 문서 생성 중 오류가 발생했습니다. 네트워크 연결이나 파일 권한을 확인해주세요. </p> - <Button onClick={handleRegenerateDocument} disabled={isGenerating}> + <Button onClick={handleRegenerateDocument} disabled={isGenerating || isSaving || isConverting}> <RefreshCw className="mr-2 h-4 w-4" /> 다시 시도 </Button> @@ -249,7 +504,7 @@ export function PreviewDocumentDialog({ ) : documentGenerated ? ( <ClausePreviewViewer clauses={clauses} - document={document} + document={contractDocument} instance={viewerInstance} setInstance={setViewerInstance} onSuccess={handleViewerSuccess} @@ -259,7 +514,7 @@ export function PreviewDocumentDialog({ <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/10"> <FileText className="h-12 w-12 text-muted-foreground mb-4" /> <p className="text-lg font-medium mb-2">문서 미리보기 준비 중</p> - <Button onClick={handleGeneratePreview} disabled={isGenerating}> + <Button onClick={handleGeneratePreview} disabled={isGenerating || isSaving || isConverting}> <Eye className="mr-2 h-4 w-4" /> 미리보기 생성 </Button> diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 8189381b..057526cf 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -65,7 +65,7 @@ import { sendEmail } from "../mail/sendEmail"; import { headers } from 'next/headers';
import { filterColumns } from "@/lib/filter-columns";
import { differenceInDays, addYears, isBefore } from "date-fns";
-import { deleteFile, saveFile } from "@/lib/file-stroage";
+import { deleteFile, saveBuffer, saveFile } from "@/lib/file-stroage";
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
@@ -3407,3 +3407,89 @@ export async function updateVendorDocumentStatus( return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." }
}
}
+
+
+interface SaveDocumentParams {
+ documentId: number
+ pdfBuffer: Uint8Array
+ originalFileName: string
+ vendor: {
+ vendorName: string
+ }
+ userId: number
+}
+
+export async function saveGtcDocumentAction({
+ documentId,
+ pdfBuffer,
+ originalFileName,
+ vendor
+}: SaveDocumentParams) {
+ try {
+ console.log("📄 GTC 문서 저장 시작:", {
+ documentId,
+ vendorName: vendor.vendorName,
+ originalFileName,
+ bufferSize: pdfBuffer.length
+ })
+
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return { success: false, error: "인증되지 않은 사용자입니다." }
+ }
+ const userId = Number(session.user.id);
+
+ // 1. PDF 파일명 생성
+ const baseName = originalFileName.replace(/\.[^/.]+$/, "") // 확장자 제거
+ const fileName = `GTC_${vendor.vendorName}_${baseName}_${new Date().toISOString().split('T')[0]}.pdf`
+
+ // 2. 파일 저장 (공용 파일 저장 함수 사용)
+ const saveResult = await saveBuffer({
+ buffer: Buffer.from(pdfBuffer),
+ fileName,
+ directory: 'basic-contracts',
+ originalName: fileName,
+ userId: userId.toString()
+ })
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || '파일 저장 실패')
+ }
+
+ // 3. 데이터베이스 업데이트
+ await db.update(basicContract)
+ .set({
+ fileName: saveResult.fileName!,
+ filePath: saveResult.publicPath!,
+ status: 'PENDING',
+ // 기존 서명 관련 timestamp들 리셋
+ vendorSignedAt: null,
+ buyerSignedAt: null,
+ legalReviewRequestedAt: null,
+ legalReviewCompletedAt: null,
+ updatedAt: new Date()
+ })
+ .where(eq(basicContract.id, documentId))
+
+ console.log("✅ GTC 문서 저장 완료:", {
+ fileName: saveResult.fileName,
+ filePath: saveResult.publicPath,
+ fileSize: saveResult.fileSize
+ })
+
+ return {
+ success: true,
+ fileName: saveResult.fileName,
+ filePath: saveResult.publicPath,
+ fileSize: saveResult.fileSize
+ }
+
+ } catch (error) {
+ console.error("❌ GTC 문서 저장 실패:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '문서 저장 중 오류가 발생했습니다'
+ }
+ }
+}
\ No newline at end of file |
