summaryrefslogtreecommitdiff
path: root/lib/basic-contract
diff options
context:
space:
mode:
Diffstat (limited to 'lib/basic-contract')
-rw-r--r--lib/basic-contract/gtc-vendor/clause-table.tsx5
-rw-r--r--lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx9
-rw-r--r--lib/basic-contract/gtc-vendor/preview-document-dialog.tsx331
-rw-r--r--lib/basic-contract/service.ts88
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