diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-15 14:41:01 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-15 14:41:01 +0000 |
| commit | 4ee8b24cfadf47452807fa2af801385ed60ab47c (patch) | |
| tree | e1d1fb029f0cf5519c517494bf9a545505c35700 /app | |
| parent | 265859d691a01cdcaaf9154f93c38765bc34df06 (diff) | |
(대표님) 작업사항 - rfqLast, tbeLast, pdfTron, userAuth
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/rfq-last/[id]/compare/page.tsx | 69 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx | 3 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/tbe-last/page.tsx | 88 | ||||
| -rw-r--r-- | app/[lng]/pdftron-viewer/page.tsx | 507 | ||||
| -rw-r--r-- | app/api/contracts/prepare-template/route.ts | 4 | ||||
| -rw-r--r-- | app/api/document-reviews/[id]/route.ts | 138 | ||||
| -rw-r--r-- | app/api/files/[...path]/route.ts | 38 | ||||
| -rw-r--r-- | app/api/partners/rfq-last/[id]/response/route.ts | 12 | ||||
| -rw-r--r-- | app/api/partners/tbe/[sessionId]/documents/route.ts | 275 | ||||
| -rw-r--r-- | app/api/pdftron-comments/xfdf/count/route.ts | 171 | ||||
| -rw-r--r-- | app/api/pdftron-comments/xfdf/route.ts | 362 | ||||
| -rw-r--r-- | app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts | 131 | ||||
| -rw-r--r-- | app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts | 57 | ||||
| -rw-r--r-- | app/api/upload/signed-contract/route.ts | 43 |
14 files changed, 1864 insertions, 34 deletions
diff --git a/app/[lng]/evcp/(evcp)/rfq-last/[id]/compare/page.tsx b/app/[lng]/evcp/(evcp)/rfq-last/[id]/compare/page.tsx new file mode 100644 index 00000000..097b99eb --- /dev/null +++ b/app/[lng]/evcp/(evcp)/rfq-last/[id]/compare/page.tsx @@ -0,0 +1,69 @@ +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import { QuotationCompareView } from "@/lib/rfq-last/quotation-compare-view"; +import { Loader2 } from "lucide-react"; +import { getComparisonData } from "@/lib/rfq-last/compare-action"; + +interface ComparePageProps { + params: { + id: string; + }; + searchParams: { + vendors?: string; + }; +} + +export default async function ComparePage({ + params, + searchParams +}: ComparePageProps) { + const rfqId = parseInt(params.id); + + console.log(rfqId,"rfqId") + console.log(searchParams.vendors,"searchParams.vendors") + + // URL에서 벤더 ID들 파싱 + const vendorIds = searchParams.vendors + ?.split(',') + .map(id => parseInt(id)) + .filter(id => !isNaN(id)) || []; + + if (!rfqId || vendorIds.length < 2) { + notFound(); + } + + // 서버에서 데이터 가져오기 + const data = await getComparisonData(rfqId, vendorIds); + + if (!data) { + notFound(); + } + + return ( + <div className="container mx-auto p-6 space-y-6"> + {/* 페이지 헤더 */} + <div className="flex items-center justify-between"> + <div> + <h1 className="text-2xl font-bold">견적 비교</h1> + <p className="text-muted-foreground"> + {data.rfqInfo.rfqCode} - {data.rfqInfo.rfqTitle} + </p> + </div> + <div className="text-sm text-muted-foreground"> + 비교 업체: {data.vendors.length}개 + </div> + </div> + + {/* 비교 뷰 컴포넌트 */} + <Suspense + fallback={ + <div className="flex items-center justify-center h-64"> + <Loader2 className="h-8 w-8 animate-spin" /> + </div> + } + > + <QuotationCompareView data={data} /> + </Suspense> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx b/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx index a0e278cb..7a68e3a2 100644 --- a/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx +++ b/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx @@ -135,9 +135,6 @@ export default async function VendorResponsePage({ params }: PageProps) { ) .orderBy(basicContract.createdAt) - console.log(basicContracts,"basicContracts") - console.log(rfqDetail,"rfqDetail") - return ( <div className="container mx-auto py-8"> diff --git a/app/[lng]/partners/(partners)/tbe-last/page.tsx b/app/[lng]/partners/(partners)/tbe-last/page.tsx new file mode 100644 index 00000000..62a982c7 --- /dev/null +++ b/app/[lng]/partners/(partners)/tbe-last/page.tsx @@ -0,0 +1,88 @@ +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { getTBEforVendor } from "@/lib/tbe-last/vendor-tbe-service" +import { searchParamsTBELastCache } from "@/lib/tbe-last/validations" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { TbeVendorTable } from "@/lib/tbe-last/vendor/tbe-table" +import * as React from "react" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { InformationButton } from "@/components/information/information-button" +interface IndexPageProps { + // Next.js 13 App Router에서 기본으로 주어지는 객체들 + params: { + lng: string + id: string + } + searchParams: Promise<SearchParams> +} + +export default async function RfqTBEPage(props: IndexPageProps) { + const resolvedParams = await props.params + const lng = resolvedParams.lng + + // 2) SearchParams 파싱 (Zod) + // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 + const searchParams = await props.searchParams + const search = searchParamsTBELastCache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + const session = await getServerSession(authOptions) + const vendorId = session?.user.companyId + // const vendorId = "17" + + const idAsNumber = Number(vendorId) + + const promises = Promise.all([ + getTBEforVendor({ + ...search, + filters: validFilters, + }, + idAsNumber) + ]) + + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + TBE 관리 + </h2> + <InformationButton pagePath="partners/tbe" /> + </div> + {/* <p className="text-sm text-muted-foreground"> + TBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "} + </p> */} + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* <DateRangePicker + triggerSize="sm" + triggerClassName="ml-auto w-56 sm:w-60" + align="end" + shallow={false} + /> */} + </React.Suspense> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <TbeVendorTable promises={promises} /> + </React.Suspense> + </Shell> + ) +} diff --git a/app/[lng]/pdftron-viewer/page.tsx b/app/[lng]/pdftron-viewer/page.tsx new file mode 100644 index 00000000..bde60a41 --- /dev/null +++ b/app/[lng]/pdftron-viewer/page.tsx @@ -0,0 +1,507 @@ +// app/pdftron-viewer/page.tsx + +"use client" + +import * as React from "react" +import { useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { ArrowLeft, MessageSquare, Download, Upload } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { useSession } from "next-auth/react" +import { useToast } from "@/hooks/use-toast" +import type { WebViewerInstance } from "@pdftron/webviewer" + +// PDFTron 코멘트 타입 정의 +interface PDFTronComment { + id: number + documentReviewId: number + pdftronDocumentId: string + xfdfString: string + annotationData: any + commentSummary?: { + total: number + open: number + resolved: number + rejected: number + deferred: number + byCategory: Record<string, number> + bySeverity: Record<string, number> + byAuthor: Record<string, number> + } + createdBy: number + createdByName?: string + createdByType: "buyer" | "vendor" + createdAt: Date + updatedAt: Date +} + +export default function PDFTronViewerPage() { + const { data: session, status } = useSession() + const searchParams = useSearchParams() + const viewerRef = React.useRef<HTMLDivElement>(null) + const [instance, setInstance] = React.useState<WebViewerInstance | null>(null) + const [isLoading, setIsLoading] = React.useState(true) + const [lastSavedTime, setLastSavedTime] = React.useState<Date | null>(null) + const [isSaving, setIsSaving] = React.useState(false) + const [annotationCount, setAnnotationCount] = React.useState(0) + const { toast } = useToast() + const initialized = React.useRef(false) + const isCancelled = React.useRef(false) + const autoSaveTimerRef = React.useRef<NodeJS.Timeout | null>(null) + const xfdfLoadedRef = React.useRef(false) // XFDF 로딩 완료 여부 추적 + + // URL 파라미터에서 정보 가져오기 + const filePath = searchParams.get('filePath') + const documentId = searchParams.get('documentId') + const documentReviewId = searchParams.get('documentReviewId') + const sessionId = searchParams.get('sessionId') + const documentName = searchParams.get('documentName') + + // PDFTron WebViewer 초기화 - session과 XFDF 모두 준비된 후 실행 + React.useEffect(() => { + if (!initialized.current && viewerRef.current && filePath && session && documentReviewId) { + initialized.current = true + isCancelled.current = false + + // XFDF 먼저 로드한 후 WebViewer 초기화 + loadAndInitializeViewer() + } + + return () => { + if (instance) { + try { + instance.UI.dispose() + } catch (error) { + console.warn("Error disposing viewer:", error) + } + } + isCancelled.current = true + + // 타이머 정리 + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + } + }, [filePath, session, documentReviewId, sessionId]) + + const loadAndInitializeViewer = async () => { + try { + // 1. 먼저 기존 XFDF 로드 + let existingXFDF = "" + try { + const response = await fetch(`/api/pdftron-comments/xfdf?documentReviewId=${documentReviewId}`) + if (response.ok) { + const data = await response.json() + if (data.xfdfString) { + existingXFDF = data.xfdfString + console.log("Loaded existing XFDF successfully") + } + } + } catch (error) { + console.error("Failed to load XFDF:", error) + } + + // 2. WebViewer 초기화 + await initializeWebViewer(existingXFDF) + + } catch (error) { + console.error("Failed to initialize viewer:", error) + setIsLoading(false) + toast({ + title: "Error", + description: "Failed to initialize document viewer", + variant: "destructive" + }) + } + } + + const initializeWebViewer = async (existingXFDF: string) => { + try { + console.log("Starting WebViewer initialization...") + console.log("File path:", filePath) + console.log("Current session:", session) + console.log("Has existing XFDF:", !!existingXFDF) + + // 동적 import 사용 + const { default: WebViewer } = await import("@pdftron/webviewer") + + if (isCancelled.current || !viewerRef.current) { + console.log("WebViewer initialization cancelled") + return + } + + // WebViewer 인스턴스 생성 + const webviewerInstance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_LICENSE_KEY || process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + initialDoc: filePath!, + }, + viewerRef.current + ) + + if (isCancelled.current) { + console.log("WebViewer initialization cancelled after creation") + return + } + + setInstance(webviewerInstance) + + if (!webviewerInstance.Core) { + console.error("WebViewer Core is not available") + setIsLoading(false) + return + } + + const { documentViewer, annotationManager, Annotations } = webviewerInstance.Core + + // 현재 사용자 설정 + const currentUser = session?.user?.email || session?.user?.name || 'Anonymous' + console.log("Setting current user:", currentUser) + annotationManager.setCurrentUser(currentUser) + + // 권한 설정 - 자기 annotation만 수정/삭제 가능 + annotationManager.setPermissionCheckCallback((author: string, annotation: any) => { + // 자기가 만든 annotation만 수정 가능 + return author === currentUser + }) + + // 문서 로드 완료 시 + documentViewer.addEventListener('documentLoaded', async () => { + console.log("Document loaded successfully") + setIsLoading(false) + + console.log(existingXFDF) + + // 기존 XFDF 적용 + if (existingXFDF && !xfdfLoadedRef.current) { + console.log(existingXFDF, "existingXFDF") + + try { + await annotationManager.importAnnotations(existingXFDF) + xfdfLoadedRef.current = true + console.log("Imported existing annotations from XFDF") + + // 초기 annotation 수 설정 + const annotations = annotationManager.getAnnotationsList() + setAnnotationCount(annotations.length) + + // 마지막 저장 시간 설정 + setLastSavedTime(new Date()) + } catch (error) { + console.error("Failed to import XFDF:", error) + toast({ + title: "Warning", + description: "Failed to load existing annotations", + variant: "destructive" + }) + } + } + + // UI 설정 (1초 지연) + setTimeout(() => { + setupUI() + }, 1000) + }) + + // UI 설정 함수 + const setupUI = async () => { + try { + console.log("Setting up UI features...") + + // Review 모드 annotation 도구 활성화 + try { + // 주석 도구 활성화 + webviewerInstance.UI.enableElements(['highlightToolButton']) + webviewerInstance.UI.enableElements(['stickyToolButton']) + webviewerInstance.UI.enableElements(['freeTextToolButton']) + webviewerInstance.UI.enableElements(['underlineToolButton']) + webviewerInstance.UI.enableElements(['strikeoutToolButton']) + webviewerInstance.UI.enableElements(['squigglyToolButton']) + + // 노트 패널 열기 + webviewerInstance.UI.openElements(['notesPanel']) + } catch (e) { + console.log("Could not enable annotation tools:", e) + } + + // 커스텀 이벤트 리스너 설정 + setupAnnotationListeners() + } catch (error) { + console.error("Error setting up UI:", error) + } + } + + // Annotation 이벤트 리스너 설정 + const setupAnnotationListeners = () => { + // 자동 저장 함수 + const handleAutoSave = async () => { + if (!documentReviewId) { + console.log("No documentReviewId, skipping auto-save") + return + } + + // 이미 저장 중이면 스킵 + if (isSaving) { + console.log("Already saving, skipping...") + return + } + + setIsSaving(true) + + try { + const xfdfString = await annotationManager.exportAnnotations() + + // Annotation 요약 정보 생성 + const annotations = annotationManager.getAnnotationsList() + const summary = { + total: annotations.length, + open: annotations.filter((a: any) => a.getCustomData('status') !== 'resolved').length, + resolved: annotations.filter((a: any) => a.getCustomData('status') === 'resolved').length, + rejected: annotations.filter((a: any) => a.getCustomData('status') === 'rejected').length, + deferred: annotations.filter((a: any) => a.getCustomData('status') === 'deferred').length, + byCategory: {} as Record<string, number>, + bySeverity: {} as Record<string, number>, + byAuthor: {} as Record<string, number> + } + + annotations.forEach((annotation: any) => { + const category = annotation.getCustomData('category') || 'general' + const severity = annotation.getCustomData('severity') || 'minor' + const author = annotation.Author || 'Anonymous' + + summary.byCategory[category] = (summary.byCategory[category] || 0) + 1 + summary.bySeverity[severity] = (summary.bySeverity[severity] || 0) + 1 + summary.byAuthor[author] = (summary.byAuthor[author] || 0) + 1 + }) + + // 서버에 저장 + const response = await fetch('/api/pdftron-comments/xfdf', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + documentReviewId: parseInt(documentReviewId), + sessionId: sessionId ? parseInt(sessionId) :0, + pdftronDocumentId: documentId, + xfdfString: xfdfString, + commentSummary: summary, + createdByType: 'buyer' + }) + }) + + if (response.ok) { + setLastSavedTime(new Date()) + setAnnotationCount(annotations.length) + console.log("Auto-save successful") + } else { + console.error("Auto-save failed") + toast({ + title: "Error", + description: "Failed to save annotations", + variant: "destructive" + }) + } + } catch (error) { + console.error("Auto-save error:", error) + toast({ + title: "Error", + description: "Failed to save annotations", + variant: "destructive" + }) + } finally { + setIsSaving(false) + } + } + + // Annotation 변경 감지 + annotationManager.addEventListener('annotationChanged', (annotations: any[], action: string) => { + if (action === 'add' || action === 'modify' || action === 'delete') { + // 새 annotation에 기본 메타데이터 추가 + if (action === 'add') { + annotations.forEach(annotation => { + if (!annotation.getCustomData('category')) { + annotation.setCustomData('category', 'general') + annotation.setCustomData('severity', 'minor') + annotation.setCustomData('status', 'open') + annotation.setCustomData('createdBy', session?.user?.id || '') + annotation.setCustomData('createdByType', 'buyer') + annotation.setCustomData('createdAt', new Date().toISOString()) + + // 기본 색상 설정 (minor = yellow) + try { + if (Annotations) { + annotation.Color = new Annotations.Color(250, 204, 21) + } + } catch (e) { + console.log("Could not set annotation color") + } + } + }) + } + + // 자동 저장 - 2초 디바운싱 + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + + autoSaveTimerRef.current = setTimeout(() => { + console.log("Auto-saving annotations...") + handleAutoSave() + }, 2000) + } + }) + + // 코멘트 변경 감지 + annotationManager.addEventListener('annotationCommentsChanged', () => { + // 자동 저장 - 1.5초 디바운싱 + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + + autoSaveTimerRef.current = setTimeout(() => { + console.log("Auto-saving comments...") + handleAutoSave() + }, 1500) + }) + + // Annotation 선택 시 기본값 설정 + annotationManager.addEventListener('annotationSelected', (annotations: any, action: string) => { + if (annotations && annotations.length > 0) { + const annotation = annotations[0] + + // 기본 커스텀 데이터 설정 + if (!annotation.getCustomData('category')) { + annotation.setCustomData('category', 'general') + annotation.setCustomData('severity', 'minor') + annotation.setCustomData('status', 'open') + annotation.setCustomData('createdBy', session?.user?.id || '') + annotation.setCustomData('createdByType', 'buyer') + annotation.setCustomData('createdAt', new Date().toISOString()) + } + } + }) + } + + } catch (error) { + console.error("WebViewer initialization failed:", error) + setIsLoading(false) + toast({ + title: "Error", + description: "Failed to initialize document viewer", + variant: "destructive" + }) + } + } + + + + // 통계 정보 가져오기 + const getAnnotationStats = () => { + if (!instance) return null + + const { annotationManager } = instance.Core + const annotations = annotationManager.getAnnotationsList() + + return { + total: annotations.length, + open: annotations.filter((a: any) => a.getCustomData('status') !== 'resolved').length, + resolved: annotations.filter((a: any) => a.getCustomData('status') === 'resolved').length + } + } + + // 시간 포맷팅 + const formatLastSaved = () => { + if (!lastSavedTime) return null + + const now = new Date() + const diff = Math.floor((now.getTime() - lastSavedTime.getTime()) / 1000) + + if (diff < 60) return "Just saved" + if (diff < 3600) return `Saved ${Math.floor(diff / 60)} min ago` + if (diff < 86400) return `Saved ${Math.floor(diff / 3600)} hours ago` + return `Saved ${Math.floor(diff / 86400)} days ago` + } + + const stats = getAnnotationStats() + const lastSavedText = formatLastSaved() + + return ( + <div className="flex flex-col h-screen overflow-hidden"> + {/* Header */} + <div className="flex items-center justify-between p-4 border-b bg-background flex-shrink-0"> + <div className="flex items-center gap-4"> + <Button + variant="ghost" + size="sm" + onClick={() => window.close()} + > + <ArrowLeft className="h-4 w-4 mr-2" /> + Back + </Button> + <div> + <h1 className="text-lg font-semibold">{documentName || 'Document Viewer'}</h1> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <span>Review Mode</span> + <span>•</span> + <span>User: {session?.user?.email || session?.user?.name || 'Loading...'}</span> + {stats && stats.total > 0 && ( + <> + <span>•</span> + <Badge variant="outline"> + <MessageSquare className="h-3 w-3 mr-1" /> + {stats.open} open / {stats.total} total + </Badge> + </> + )} + {isSaving && ( + <> + <span>•</span> + <Badge variant="outline" className="text-blue-600 border-blue-600"> + <div className="animate-pulse">Auto-saving...</div> + </Badge> + </> + )} + {!isSaving && lastSavedText && ( + <> + <span>•</span> + <Badge variant="outline" className="text-green-600 border-green-600"> + ✓ {lastSavedText} + </Badge> + </> + )} + </div> + </div> + </div> + + </div> + + {/* PDFTron Viewer */} + <div className="flex-1 relative overflow-hidden"> + {(isLoading || status === "loading") && ( + <div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10"> + <div className="text-center"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div> + <p className="text-sm text-muted-foreground"> + {status === "loading" ? "Loading session..." : "Loading document..."} + </p> + <p className="text-xs text-muted-foreground mt-1"> + Initializing PDFTron viewer... + </p> + </div> + </div> + )} + <div + ref={viewerRef} + className="h-full w-full" + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }} + /> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/app/api/contracts/prepare-template/route.ts b/app/api/contracts/prepare-template/route.ts index 189643b5..7d0f39c6 100644 --- a/app/api/contracts/prepare-template/route.ts +++ b/app/api/contracts/prepare-template/route.ts @@ -5,7 +5,7 @@ import { eq, and, ilike } from "drizzle-orm"; export async function POST(request: NextRequest) { try { - const { templateName, vendorId } = await request.json(); + const { templateName, vendorId, biddingId, biddingCompanyId } = await request.json(); // 템플릿 조회 const [template] = await db @@ -65,7 +65,7 @@ export async function POST(request: NextRequest) { business_size: vendor.businessSize || '', credit_rating: vendor.creditRating || '', template_type: templateName, - contract_number: `BC-${new Date().getFullYear()}-${String(vendorId).padStart(4, '0')}-${Date.now()}`, + contract_number: `BC-${new Date().getFullYear()}-${biddingId || '0'}-${String(vendorId).padStart(4, '0')}-${Date.now()}`, }; return NextResponse.json({ diff --git a/app/api/document-reviews/[id]/route.ts b/app/api/document-reviews/[id]/route.ts new file mode 100644 index 00000000..472f93bf --- /dev/null +++ b/app/api/document-reviews/[id]/route.ts @@ -0,0 +1,138 @@ +// app/api/document-reviews/[id]/route.ts + +import { NextRequest, NextResponse } from "next/server" +import db from "@/db/db" +import { rfqLastTbeDocumentReviews } from "@/db/schema" +import { eq } from "drizzle-orm" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { revalidateTag } from "next/cache" + +// PATCH - 문서 리뷰 업데이트 +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }) + } + + const reviewId = parseInt(params.id) + if (!reviewId) { + return NextResponse.json({ error: "Invalid review ID" }, { status: 400 }) + } + + const body = await request.json() + const { reviewStatus, reviewComments } = body + + // 현재 문서 리뷰 조회 + const [currentReview] = await db + .select() + .from(rfqLastTbeDocumentReviews) + .where(eq(rfqLastTbeDocumentReviews.id, reviewId)) + .limit(1) + + if (!currentReview) { + return NextResponse.json({ error: "Review not found" }, { status: 404 }) + } + + // 권한 체크 - 구매자만 리뷰 가능 (또는 admin) + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + const isAdmin = (session.user as any).roles?.includes('admin') || false + + // 여기서는 구매자 권한 체크를 간단히 처리 + // 실제로는 세션의 role이나 type을 확인해야 함 + + // 업데이트할 데이터 준비 + const updateData: any = { + updatedAt: new Date() + } + + if (reviewStatus !== undefined) { + updateData.reviewStatus = reviewStatus + } + + if (reviewComments !== undefined) { + updateData.reviewComments = reviewComments + } + + // 리뷰 상태가 변경되면 관련 필드도 업데이트 + if (reviewStatus && reviewStatus !== currentReview.reviewStatus) { + updateData.reviewedBy = userId + updateData.reviewedAt = new Date() + + // 상태에 따른 추가 필드 설정 + switch (reviewStatus) { + case "승인": + updateData.technicalCompliance = true + updateData.qualityAcceptable = true + updateData.requiresRevision = false + break + case "반려": + updateData.technicalCompliance = false + updateData.qualityAcceptable = false + updateData.requiresRevision = true + break + case "보류": + updateData.requiresRevision = true + break + } + } + + // 업데이트 실행 + const [updated] = await db + .update(rfqLastTbeDocumentReviews) + .set(updateData) + .where(eq(rfqLastTbeDocumentReviews.id, reviewId)) + .returning() + + // 캐시 초기화 + if (currentReview.tbeSessionId) { + revalidateTag(`tbe-session-${currentReview.tbeSessionId}`) + } + + return NextResponse.json(updated) + } catch (error) { + console.error("Failed to update document review:", error) + return NextResponse.json({ + error: "Failed to update document review" + }, { status: 500 }) + } +} + +// GET - 문서 리뷰 조회 +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }) + } + + const reviewId = parseInt(params.id) + if (!reviewId) { + return NextResponse.json({ error: "Invalid review ID" }, { status: 400 }) + } + + const [review] = await db + .select() + .from(rfqLastTbeDocumentReviews) + .where(eq(rfqLastTbeDocumentReviews.id, reviewId)) + .limit(1) + + if (!review) { + return NextResponse.json({ error: "Review not found" }, { status: 404 }) + } + + return NextResponse.json(review) + } catch (error) { + console.error("Failed to fetch document review:", error) + return NextResponse.json({ + error: "Failed to fetch document review" + }, { status: 500 }) + } +}
\ No newline at end of file diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index 3fb60347..88211f5b 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -31,6 +31,7 @@ const getMimeType = (filePath: string): string => { const isAllowedPath = (requestedPath: string): boolean => { const allowedPaths = [ 'basicContract', + 'contracts', 'basicContract/template', 'basicContract/signed', 'vendorFormReportSample', @@ -64,7 +65,12 @@ export async function GET( ) { try { // 요청된 파일 경로 구성 - const requestedPath = params.path.join('/'); + const decodedPath = params.path.map(segment => + decodeURIComponent(segment) + ); + + // 디코딩된 경로로 조합 + const requestedPath = decodedPath.join('/'); console.log(`📂 파일 요청: ${requestedPath}`); @@ -124,10 +130,14 @@ export async function GET( console.log(`✅ 파일 서빙 성공: ${fileName} (${stats.size} bytes)`); - // ✅ Content-Disposition 헤더 결정 + const encodedFileName = encodeURIComponent(fileName) + .replace(/'/g, "%27") + .replace(/"/g, "%22"); + const contentDisposition = forceDownload - ? `attachment; filename="${fileName}"` // 강제 다운로드 - : `inline; filename="${fileName}"`; // 브라우저에서 열기 + ? `attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}` + : `inline; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`; + // Range 요청 처리 (큰 파일의 부분 다운로드 지원) const range = request.headers.get('range'); @@ -176,7 +186,12 @@ export async function HEAD( { params }: { params: { path: string[] } } ) { try { - const requestedPath = params.path.join('/'); + const decodedPath = params.path.map(segment => + decodeURIComponent(segment) + ); + + // 디코딩된 경로로 조합 + const requestedPath = decodedPath.join('/'); // ✅ HEAD 요청에서도 다운로드 강제 여부 확인 const url = new URL(request.url); @@ -207,11 +222,16 @@ export async function HEAD( const mimeType = getMimeType(filePath); const fileName = path.basename(filePath); - // ✅ HEAD 요청에서도 Content-Disposition 헤더 적용 - const contentDisposition = forceDownload - ? `attachment; filename="${fileName}"` // 강제 다운로드 - : `inline; filename="${fileName}"`; // 브라우저에서 열기 + const encodedFileName = encodeURIComponent(fileName) + .replace(/'/g, "%27") + .replace(/"/g, "%22"); + + const contentDisposition = forceDownload + ? `attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}` + : `inline; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`; + + return new NextResponse(null, { headers: { 'Content-Type': mimeType, diff --git a/app/api/partners/rfq-last/[id]/response/route.ts b/app/api/partners/rfq-last/[id]/response/route.ts index db320dde..1fc9d5dd 100644 --- a/app/api/partners/rfq-last/[id]/response/route.ts +++ b/app/api/partners/rfq-last/[id]/response/route.ts @@ -156,7 +156,10 @@ export async function POST( const fileRecords = [] if (files.length > 0) { - for (const file of files) { + for (let i = 0; i < files.length; i++) { + const file = files[i] + const metadata = data.fileMetadata?.[i] // 인덱스로 메타데이터 매칭 + try { const filename = `${uuidv4()}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}` const filepath = path.join(uploadDir, filename) @@ -165,28 +168,25 @@ export async function POST( if (file.size > 50 * 1024 * 1024) { // 50MB 이상 await saveFileStream(file, filepath) } else { - // 작은 파일은 기존 방식 const buffer = Buffer.from(await file.arrayBuffer()) await writeFile(filepath, buffer) } fileRecords.push({ vendorResponseId: result.id, - attachmentType: (file as any).attachmentType || "기타", + attachmentType: metadata?.attachmentType || "기타", // 메타데이터에서 가져옴 fileName: filename, originalFileName: file.name, filePath: `/uploads/rfq/${rfqId}/${filename}`, fileSize: file.size, fileType: file.type, - description: (file as any).description, + description: metadata?.description || "", // 메타데이터에서 가져옴 uploadedBy: session.user.id, }) } catch (fileError) { console.error(`Failed to save file ${file.name}:`, fileError) - // 파일 저장 실패 시 계속 진행 (다른 파일들은 저장) } } - // DB에 파일 정보 저장 if (fileRecords.length > 0) { await db.insert(rfqLastVendorAttachments).values(fileRecords) diff --git a/app/api/partners/tbe/[sessionId]/documents/route.ts b/app/api/partners/tbe/[sessionId]/documents/route.ts new file mode 100644 index 00000000..0045ea43 --- /dev/null +++ b/app/api/partners/tbe/[sessionId]/documents/route.ts @@ -0,0 +1,275 @@ +// app/api/partners/tbe/[sessionId]/documents/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import db from "@/db/db" +import { + rfqLastTbeDocumentReviews, + rfqLastTbeSessions, + rfqLastTbeHistory, + rfqLastTbeVendorDocuments +} from "@/db/schema" +import { eq, and } from "drizzle-orm" +import { writeFile, mkdir } from "fs/promises" +import { createWriteStream } from "fs" +import { pipeline } from "stream/promises" +import path from "path" +import { v4 as uuidv4 } from "uuid" + +// 1GB 파일 지원을 위한 설정 +export const config = { + api: { + bodyParser: { + sizeLimit: '1gb', + }, + responseLimit: false, + }, +} + +// 스트리밍으로 파일 저장 +async function saveFileStream(file: File, filepath: string) { + const stream = file.stream() + const writeStream = createWriteStream(filepath) + await pipeline(stream, writeStream) +} + +// POST: TBE 문서 업로드 +export async function POST(request: NextRequest, { params }: { params: { sessionId: string } }) { + try { + const session = await getServerSession(authOptions) + if (!session?.user || session.user.domain !== "partners") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const tbeSessionId = Number(params.sessionId) + const formData = await request.formData() + + // ✅ 프런트 기frfqLastTbeVendorDocuments본값 'other' 등을 안전한 enum으로 매핑 + const documentType = (formData.get("documentType") as string | undefined) + const documentName = (formData.get("documentName") as string | undefined)?.trim() || "Untitled" + const description = (formData.get("description") as string | undefined) || "" + const file = formData.get("file") as File | null + + if (!file) { + return NextResponse.json({ error: "파일이 필요합니다" }, { status: 400 }) + } + + // 세션/권한 + const tbeSession = await db.query.rfqLastTbeSessions.findFirst({ + where: eq(rfqLastTbeSessions.id, tbeSessionId), + with: { vendor: true }, + }) + if (!tbeSession) return NextResponse.json({ error: "TBE 세션을 찾을 수 없습니다" }, { status: 404 }) + + // 권한 체크: 회사 기준으로 통일 (위/아래 GET도 동일 기준을 권장) + if (tbeSession.vendor?.id !== session.user.companyId) { + return NextResponse.json({ error: "권한이 없습니다" }, { status: 403 }) + } + + // 저장 경로 + const isDev = process.env.NODE_ENV === "development" + const uploadDir = isDev + ? path.join(process.cwd(), "public", "uploads", "tbe", String(tbeSessionId), "vendor") + : path.join(process.env.NAS_PATH || "/nas", "uploads", "tbe", String(tbeSessionId), "vendor") + + await mkdir(uploadDir, { recursive: true }) + + const safeOriginal = file.name.replace(/[^a-zA-Z0-9.\-_\s]/g, "_") + const filename = `${uuidv4()}_${safeOriginal}` + const filepath = path.join(uploadDir, filename) + + try { + if (file.size > 50 * 1024 * 1024) { + await saveFileStream(file, filepath) + } else { + const buffer = Buffer.from(await file.arrayBuffer()) + await writeFile(filepath, buffer) + } + } catch (e) { + console.error("파일 저장 실패:", e) + return NextResponse.json({ error: "파일 저장에 실패했습니다" }, { status: 500 }) + } + + // 트랜잭션 + const result = await db.transaction(async (tx) => { + // 1) 벤더 업로드 문서 insert + const [vendorDoc] = await tx + .insert(rfqLastTbeVendorDocuments) + .values({ + tbeSessionId, + documentType, // enum 매핑된 값 + isResponseToReviewId: null, // 필요 시 formData에서 받아 세팅 + fileName: filename, + originalFileName: file.name, + filePath: `/uploads/tbe/${tbeSessionId}/vendor/${filename}`, + fileSize: Number(file.size), + fileType: file.type || null, + documentNo: null, + revisionNo: null, + issueDate: null, + description, + submittalRemarks: null, + reviewRequired: true, + reviewStatus: "pending", + submittedBy: session.user.id, + submittedAt: new Date(), + reviewedBy: null, + reviewedAt: null, + reviewComments: null, + }) + .returning() + + // 2) (선택) 기존 리뷰 테이블에도 “벤더가 올린 검토대상 문서”로 남기고 싶다면 유지 + // 필요 없다면 아래 블록은 제거 가능 + const [documentReview] = await tx + .insert(rfqLastTbeDocumentReviews) + .values({ + tbeSessionId, + vendorAttachmentId:vendorDoc.id, + documentSource: "vendor", + documentType: documentType, // 동일 매핑 + documentName: documentName, // UX 표시용 이름 + reviewStatus: "미검토", + reviewComments: description, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + + // 3) 세션 상태 전환 + if (tbeSession.status === "준비중") { + await tx + .update(rfqLastTbeSessions) + .set({ + status: "진행중", + actualStartDate: new Date(), + updatedAt: new Date(), + updatedBy: session.user.id, + }) + .where(eq(rfqLastTbeSessions.id, tbeSessionId)) + } + + // 4) 이력 + await tx.insert(rfqLastTbeHistory).values({ + tbeSessionId, + actionType: "document_review", + changeDescription: `벤더 문서 업로드: ${documentName}`, + changeDetails: { + vendorDocumentId: vendorDoc.id, + documentReviewId: documentReview.id, + documentName: documentName, + documentType: documentType, + filePath: vendorDoc.filePath, + }, + performedBy: session.user.id, + performedByType: "vendor", + performedAt: new Date(), + }) + + if (tbeSession.status === "준비중") { + await tx.insert(rfqLastTbeHistory).values({ + tbeSessionId, + actionType: "status_change", + previousStatus: "준비중", + newStatus: "진행중", + changeDescription: "벤더 문서 업로드로 인한 상태 변경", + performedBy: session.user.id, + performedByType: "vendor", + performedAt: new Date(), + }) + } + + return { + vendorDoc, + documentReview, + } + }) + + return NextResponse.json({ + success: true, + data: { + vendorDocumentId: result.vendorDoc.id, + filePath: result.vendorDoc.filePath, + originalFileName: result.vendorDoc.originalFileName, + fileSize: result.vendorDoc.fileSize, + fileType: result.vendorDoc.fileType, + }, + message: "문서가 성공적으로 업로드되었습니다", + }) + } catch (error) { + console.error("TBE 문서 업로드 오류:", error) + return NextResponse.json({ error: "문서 업로드에 실패했습니다" }, { status: 500 }) + } +} + +// GET: TBE 세션의 문서 목록 조회 +export async function GET( + request: NextRequest, + { params }: { params: { sessionId: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user || session.user.domain !== "partners") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const tbeSessionId = parseInt(params.sessionId) + + // TBE 세션 확인 및 권한 체크 + const tbeSession = await db.query.rfqLastTbeSessions.findFirst({ + where: eq(rfqLastTbeSessions.id, tbeSessionId), + with: { + vendor: true, + documentReviews: { + orderBy: (reviews, { desc }) => [desc(reviews.createdAt)], + } + } + }) + + if (!tbeSession) { + return NextResponse.json({ error: "TBE 세션을 찾을 수 없습니다" }, { status: 404 }) + } + + // 벤더 권한 확인 + if (tbeSession.vendor.userId !== session.user.id) { + return NextResponse.json({ error: "권한이 없습니다" }, { status: 403 }) + } + + // PDFTron 코멘트 수 집계 (필요시) + const documentsWithDetails = await Promise.all( + tbeSession.documentReviews.map(async (doc) => { + // PDFTron 코멘트 수 조회 + const pdftronComments = await db.query.rfqLastTbePdftronComments.findFirst({ + where: eq(rfqLastTbePdftronComments.documentReviewId, doc.id), + }) + + return { + ...doc, + comments: pdftronComments?.commentSummary || { + totalCount: 0, + openCount: 0, + }, + } + }) + ) + + return NextResponse.json({ + success: true, + session: { + id: tbeSession.id, + sessionCode: tbeSession.sessionCode, + sessionTitle: tbeSession.sessionTitle, + sessionStatus: tbeSession.status, + evaluationResult: tbeSession.evaluationResult, + }, + documents: documentsWithDetails, + }) + + } catch (error) { + console.error("문서 목록 조회 오류:", error) + return NextResponse.json( + { error: "문서 목록 조회에 실패했습니다" }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/app/api/pdftron-comments/xfdf/count/route.ts b/app/api/pdftron-comments/xfdf/count/route.ts new file mode 100644 index 00000000..19127ea9 --- /dev/null +++ b/app/api/pdftron-comments/xfdf/count/route.ts @@ -0,0 +1,171 @@ +// app/api/pdftron-comments/xfdf/count/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import db from "@/db/db" +import { rfqLastTbePdftronComments } from "@/db/schema" +import { inArray } from "drizzle-orm" +import { parseStringPromise } from "xml2js" + +type Counts = { totalCount: number; openCount: number } + +function fromCommentSummary(summary: any | null | undefined): Counts | null { + if (!summary) return null + // commentSummary가 다음 형태를 따른다고 가정: + // { totalCount?: number, openCount?: number } 또는 유사 구조 + const t = Number((summary as any)?.totalCount) + const o = Number((summary as any)?.openCount) + if (Number.isFinite(t)) { + return { totalCount: t, openCount: Number.isFinite(o) ? o : t } + } + return null +} + +async function fromXfdfString(xfdf: string | null | undefined): Promise<Counts | null> { + if (!xfdf) return null + try { + const xml = await parseStringPromise(xfdf, { explicitArray: true }) + // XFDF 기본 구조: xfdf.annotations[0].annotation = [...] + const ann = + xml?.xfdf?.annotations?.[0]?.annotation ?? + xml?.xfdf?.fdf?.annots?.[0]?.annot ?? + [] // 방어적 + const total = Array.isArray(ann) ? ann.length : 0 + + // “오픈/클로즈드” 판단 로직은 팀의 규칙에 맞게 조정: + // - 상태(StateModel/State) 혹은 CustomData를 쓰는 경우가 많음. + // - 기본 폴백: 전부 오픈으로 간주. + let open = total + + // 예: <status>Completed</status> 이면 클로즈드로 처리 + // (실제 저장 스키마에 맞춰 커스터마이즈하세요.) + let closed = 0 + if (Array.isArray(ann)) { + for (const a of ann) { + const status = + a?.status?.[0] || + a?.["it:status"]?.[0] || + a?.state?.[0] || + a?.custom?.[0]?.status?.[0] + if ( + typeof status === "string" && + ["Completed", "Resolved", "Accepted", "Rejected", "Closed"].includes(status) + ) { + closed += 1 + } + } + } + open = Math.max(total - closed, 0) + + return { totalCount: total, openCount: open } + } catch { + return null + } +} + + +type CommentSummary = { + total?: number + open?: number + resolved?: number + rejected?: number + deferred?: number + byAuthor?: Record<string, number> + byCategory?: Record<string, number> + bySeverity?: Record<string, number> +} + +type Counts = { totalCount: number; openCount: number } + +function countsFromSummary(s?: CommentSummary | null): Counts | null { + if (!s) return null + + // 1) open이 있으면 그걸 신뢰 + if (Number.isFinite(s.open) && Number.isFinite(s.total)) { + return { totalCount: s.total!, openCount: s.open! } + } + + // 2) open이 없으면 상태 기반으로 계산 + if (Number.isFinite(s.total)) { + const resolved = Number(s.resolved ?? 0) + const rejected = Number(s.rejected ?? 0) + const deferred = Number(s.deferred ?? 0) + const open = Math.max(s.total! - resolved - rejected - deferred, 0) + return { totalCount: s.total!, openCount: open } + } + + // 3) total이 누락된 희귀 케이스 → 분포 합으로 추정 + const sum = (...recs: (Record<string, number> | undefined)[]) => + recs.reduce((acc, r) => acc + (r ? Object.values(r).reduce((a, b) => a + (b || 0), 0) : 0), 0) + + const guessedTotal = sum(s.byAuthor, s.byCategory, s.bySeverity) + if (guessedTotal > 0) { + const open = Number(s.open ?? Math.max(guessedTotal - Number(s.resolved ?? 0) - Number(s.rejected ?? 0) - Number(s.deferred ?? 0), 0)) + return { totalCount: guessedTotal, openCount: open } + } + + return null +} + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }) + } + + const idsParam = request.nextUrl.searchParams.get("ids") + if (!idsParam) { + return NextResponse.json({ error: "ids is required (comma-separated)" }, { status: 400 }) + } + + const ids = idsParam + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => Number(s)) + .filter((n) => Number.isFinite(n)) + + if (ids.length === 0) { + return NextResponse.json({ error: "no valid ids" }, { status: 400 }) + } + + // 한 번에 조회 + const rows = await db + .select() + .from(rfqLastTbePdftronComments) + .where(inArray(rfqLastTbePdftronComments.documentReviewId, ids)) + + const result: Record< + number, + { totalCount: number; openCount: number; updatedAt: string | null } + > = {} + + // 기본값: 코멘트 없음 → 0/0 + for (const id of ids) { + result[id] = { totalCount: 0, openCount: 0, updatedAt: null } + } + + // 요약 우선 → XFDF 파싱 폴백 + await Promise.all( + rows.map(async (r: any) => { + const id = Number(r.documentReviewId) + let counts = + countsFromSummary(r.commentSummary as CommentSummary) || + (await fromXfdfString(r.xfdfString)) || // 폴백 + { totalCount: 0, openCount: 0 } + + result[id] = { + totalCount: counts.totalCount, + openCount: counts.openCount, + updatedAt: r.updatedAt ?? null, + } + }) + ) + + return NextResponse.json({ data: result }) + } catch (err) { + console.error("xfdf/count GET error:", err) + return NextResponse.json({ error: "Failed to fetch counts" }, { status: 500 }) + } +} diff --git a/app/api/pdftron-comments/xfdf/route.ts b/app/api/pdftron-comments/xfdf/route.ts new file mode 100644 index 00000000..f2cd7b81 --- /dev/null +++ b/app/api/pdftron-comments/xfdf/route.ts @@ -0,0 +1,362 @@ +// app/api/pdftron-comments/xfdf/route.ts + +import { NextRequest, NextResponse } from "next/server" +import db from "@/db/db" +import { rfqLastTbePdftronComments } from "@/db/schema" +import { eq, and, desc } from "drizzle-orm" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { parseStringPromise } from "xml2js" +import { revalidateTag } from "next/cache" + +// GET - XFDF 조회 +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }) + } + + const searchParams = request.nextUrl.searchParams + const documentReviewId = searchParams.get('documentReviewId') + + if (!documentReviewId) { + return NextResponse.json({ error: "documentReviewId is required" }, { status: 400 }) + } + + // 해당 문서의 코멘트 조회 + const [comment] = await db + .select() + .from(rfqLastTbePdftronComments) + .where( + eq(rfqLastTbePdftronComments.documentReviewId, parseInt(documentReviewId)) + ) + .limit(1) + + if (!comment) { + return NextResponse.json({ xfdfString: null }) + } + + // 권한 체크 + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + const isAdmin = (session.user as any).roles?.includes('admin') || false + const canEdit = comment.createdBy === userId || isAdmin + + return NextResponse.json({ + xfdfString: comment.xfdfString, + annotationData: comment.annotationData, + commentSummary: comment.commentSummary, + canEdit: canEdit, + createdBy: comment.createdBy, + createdByType: comment.createdByType, + lastModifiedBy: comment.lastModifiedBy, + updatedAt: comment.updatedAt + }) + } catch (error) { + console.error("Failed to fetch XFDF:", error) + return NextResponse.json({ error: "Failed to fetch XFDF" }, { status: 500 }) + } +} + +// POST - XFDF 저장 (upsert 방식) +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }) + } + + const body = await request.json() + const { + documentReviewId, + sessionId, + pdftronDocumentId, + xfdfString, + commentSummary, + createdByType + } = body + + // 필수 필드 검증 + if (!documentReviewId || !pdftronDocumentId || !xfdfString) { + return NextResponse.json({ + error: "Missing required fields" + }, { status: 400 }) + } + + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + const isAdmin = (session.user as any).roles?.includes('admin') || false + + // XFDF 파싱하여 annotation 데이터 추출 + const annotationData = await parseXFDF(xfdfString) + + // 트랜잭션으로 처리 + const result = await db.transaction(async (tx) => { + // 기존 코멘트 확인 + const [existing] = await tx + .select() + .from(rfqLastTbePdftronComments) + .where( + and( + eq(rfqLastTbePdftronComments.documentReviewId, parseInt(documentReviewId)), + eq(rfqLastTbePdftronComments.pdftronDocumentId, pdftronDocumentId) + ) + ) + .limit(1) + + if (existing) { + // 권한 체크 - 다른 사용자의 annotation 수정 방지 + if (!isAdmin) { + const currentAnnotations = existing.annotationData?.annotations || [] + const newAnnotations = annotationData.annotations || [] + + // 다른 사용자가 만든 annotation이 수정/삭제되었는지 체크 + for (const oldAnn of currentAnnotations) { + // 다른 사용자가 만든 annotation + if (oldAnn.customData?.createdBy && oldAnn.customData.createdBy !== userId) { + const newAnn = newAnnotations.find((n: any) => n.id === oldAnn.id) + + // 삭제되었거나 수정되었으면 에러 + if (!newAnn || JSON.stringify(newAnn) !== JSON.stringify(oldAnn)) { + throw new Error("You can only modify your own annotations") + } + } + } + } + + // 기존 레코드 업데이트 + const [updated] = await tx + .update(rfqLastTbePdftronComments) + .set({ + xfdfString, + annotationData, + commentSummary, + lastModifiedBy: userId, + updatedAt: new Date() + }) + .where(eq(rfqLastTbePdftronComments.id, existing.id)) + .returning() + + return updated + } else { + // 새 레코드 삽입 + const [inserted] = await tx + .insert(rfqLastTbePdftronComments) + .values({ + documentReviewId: parseInt(documentReviewId), + pdftronDocumentId, + xfdfString, + annotationData, + commentSummary, + createdBy: userId, + createdByType: createdByType || 'buyer', + lastModifiedBy: userId, + createdAt: new Date(), + updatedAt: new Date() + }) + .returning() + + return inserted + } + }) + + revalidateTag(`tbe-session-${sessionId}`) + + + return NextResponse.json(result) + } catch (error: any) { + console.error("Failed to save XFDF:", error) + + if (error.message === "You can only modify your own annotations") { + return NextResponse.json({ + error: "You can only modify your own annotations" + }, { status: 403 }) + } + + return NextResponse.json({ error: "Failed to save XFDF" }, { status: 500 }) + } +} + +// DELETE - XFDF 삭제 +export async function DELETE(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }) + } + + const searchParams = request.nextUrl.searchParams + const documentReviewId = searchParams.get('documentReviewId') + const tbeSessionId = searchParams.get('sessionId') + + if (!documentReviewId) { + return NextResponse.json({ + error: "Missing required parameters" + }, { status: 400 }) + } + + const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id + const isAdmin = (session.user as any).roles?.includes('admin') || false + + // 권한 체크 + const [existing] = await db + .select() + .from(rfqLastTbePdftronComments) + .where( + eq(rfqLastTbePdftronComments.documentReviewId, parseInt(documentReviewId)) + ) + .limit(1) + + if (!existing) { + return NextResponse.json({ error: "Comment not found" }, { status: 404 }) + } + + if (existing.createdBy !== userId && !isAdmin) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }) + } + + // 삭제 + await db + .delete(rfqLastTbePdftronComments) + .where(eq(rfqLastTbePdftronComments.id, existing.id)) + + revalidateTag(`tbe-session-${tbeSessionId}`) + + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Failed to delete XFDF:", error) + return NextResponse.json({ error: "Failed to delete XFDF" }, { status: 500 }) + } +} + +// XFDF 파싱 함수 - xml2js 사용 +async function parseXFDF(xfdfString: string): Promise<any> { + try { + // xml2js로 파싱 + const result = await parseStringPromise(xfdfString, { + explicitArray: false, + ignoreAttrs: false, + mergeAttrs: false, + explicitRoot: false, + tagNameProcessors: [(name) => name.toLowerCase()] + }) + + const annotations: any[] = [] + + // annots 노드 확인 + const annots = result?.annots + if (!annots) { + return { annotations: [] } + } + + // 모든 annotation 타입 처리 + const annotTypes = [ + 'highlight', 'text', 'freetext', 'ink', 'square', + 'circle', 'line', 'polygon', 'polyline', 'stamp', + 'caret', 'fileattachment', 'sound', 'strikeout', + 'underline', 'squiggly', 'redact' + ] + + for (const type of annotTypes) { + const items = annots[type] + if (!items) continue + + // 배열이 아니면 배열로 변환 + const itemArray = Array.isArray(items) ? items : [items] + + for (const item of itemArray) { + const annotation: any = { + id: item.$?.name || '', + type: type, + page: parseInt(item.$?.page || '1'), + author: item.$?.title || '', + subject: item.$?.subject || '', + createdDate: item.$?.creationdate || '', + modifiedDate: item.$?.date || '', + } + + // contents 가져오기 + if (item.contents) { + annotation.contents = typeof item.contents === 'string' + ? item.contents + : item.contents._ || '' + } + + // color 가져오기 + if (item.$?.color) { + annotation.color = item.$.color + } + + // opacity 가져오기 + if (item.$?.opacity) { + annotation.opacity = parseFloat(item.$.opacity) + } + + // custom data 가져오기 + if (item.customdata) { + annotation.customData = {} + const properties = item.customdata.property + if (properties) { + const propArray = Array.isArray(properties) ? properties : [properties] + for (const prop of propArray) { + const name = prop.$?.name + const value = prop._ || prop + if (name && value) { + // 숫자 타입 변환 + if (name === 'createdBy' || name === 'resolvedBy') { + annotation.customData[name] = parseInt(value) + } else { + annotation.customData[name] = value + } + } + } + } + } + + // replies 가져오기 + if (item.reply) { + annotation.replies = [] + const replies = Array.isArray(item.reply) ? item.reply : [item.reply] + for (const reply of replies) { + annotation.replies.push({ + author: reply.$?.title || '', + contents: typeof reply.contents === 'string' + ? reply.contents + : reply.contents?._ || '', + createdDate: reply.$?.creationdate || '' + }) + } + } + + // coords 가져오기 (rect, vertices 등) + if (item.$?.rect) { + annotation.coords = item.$.rect.split(',').map(Number) + } else if (item.$?.vertices) { + annotation.coords = item.$.vertices.split(';').join(',').split(',').map(Number) + } else if (item.$?.coords) { + annotation.coords = item.$.coords.split(',').map(Number) + } + + // appearance 정보 + if (item.appearance) { + annotation.appearance = item.appearance + } + + // popup 정보 + if (item.popup) { + annotation.popup = { + open: item.popup.$?.open === 'true', + rect: item.popup.$?.rect + } + } + + annotations.push(annotation) + } + } + + return { annotations } + } catch (error) { + console.error("Failed to parse XFDF:", error) + return { annotations: [] } + } +}
\ No newline at end of file diff --git a/app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts b/app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts new file mode 100644 index 00000000..8308b040 --- /dev/null +++ b/app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts @@ -0,0 +1,131 @@ +// app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts + +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { + addVendorQuestion, + getVendorQuestions, + answerVendorQuestion +} from "@/lib/tbe-last/vendor-tbe-service" + +interface Props { + params: { + sessionId: string + } +} + +// GET: 질문 목록 조회 +export async function GET( + request: NextRequest, + { params }: Props +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ) + } + + const vendorId = typeof session.user.companyId === 'string' + ? parseInt(session.user.companyId) + : session.user.companyId + + const sessionId = parseInt(params.sessionId) + + const questions = await getVendorQuestions(sessionId, vendorId) + + return NextResponse.json(questions) + + } catch (error) { + console.error("Get questions error:", error) + return NextResponse.json( + { error: "Failed to get questions" }, + { status: 500 } + ) + } +} + +// POST: 새 질문 추가 +export async function POST( + request: NextRequest, + { params }: Props +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ) + } + + const vendorId = typeof session.user.companyId === 'string' + ? parseInt(session.user.companyId) + : session.user.companyId + + const sessionId = parseInt(params.sessionId) + const body = await request.json() + + const question = await addVendorQuestion( + sessionId, + vendorId, + { + category: body.category || "general", + question: body.question, + priority: body.priority || "normal", + status: "open" + } + ) + + return NextResponse.json(question) + + } catch (error) { + console.error("Add question error:", error) + return NextResponse.json( + { error: "Failed to add question" }, + { status: 500 } + ) + } +} + +// PATCH: 질문에 답변 추가 (구매자용) +export async function PATCH( + request: NextRequest, + { params }: Props +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ) + } + + const sessionId = parseInt(params.sessionId) + const body = await request.json() + + const { questionId, answer } = body + + if (!questionId || !answer) { + return NextResponse.json( + { error: "Question ID and answer are required" }, + { status: 400 } + ) + } + + const result = await answerVendorQuestion(sessionId, questionId, answer) + + return NextResponse.json(result) + + } catch (error) { + console.error("Answer question error:", error) + return NextResponse.json( + { error: "Failed to answer question" }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts b/app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts new file mode 100644 index 00000000..d2dc7797 --- /dev/null +++ b/app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts @@ -0,0 +1,57 @@ +// ========================================== +// app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts +// ========================================== + +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { updateVendorRemarks } from "@/lib/tbe-last/vendor-tbe-service" + +interface Props { + params: { + sessionId: string + } +} + +// PUT: 벤더 의견 업데이트 +export async function PUT( + request: NextRequest, + { params }: Props +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ) + } + + const vendorId = typeof session.user.companyId === 'string' + ? parseInt(session.user.companyId) + : session.user.companyId + + const sessionId = parseInt(params.sessionId) + const body = await request.json() + + const { remarks } = body + + if (!remarks) { + return NextResponse.json( + { error: "Remarks are required" }, + { status: 400 } + ) + } + + const updated = await updateVendorRemarks(sessionId, vendorId, remarks) + + return NextResponse.json(updated) + + } catch (error) { + console.error("Update remarks error:", error) + return NextResponse.json( + { error: "Failed to update remarks" }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/app/api/upload/signed-contract/route.ts b/app/api/upload/signed-contract/route.ts index 86109eec..8547f0e4 100644 --- a/app/api/upload/signed-contract/route.ts +++ b/app/api/upload/signed-contract/route.ts @@ -1,12 +1,10 @@ // app/api/upload/signed-contract/route.ts import { NextRequest, NextResponse } from 'next/server'; -import fs from 'fs/promises'; -import path from 'path'; -import { v4 as uuidv4 } from 'uuid'; import db from "@/db/db"; import { basicContract } from '@/db/schema'; import { eq } from 'drizzle-orm'; import { revalidateTag } from 'next/cache'; +import { saveBuffer } from '@/lib/file-stroage'; export async function POST(request: NextRequest) { try { @@ -19,25 +17,37 @@ export async function POST(request: NextRequest) { return NextResponse.json({ result: false, error: '필수 파라미터가 누락되었습니다.' }, { status: 400 }); } - const originalName = `${tableRowId}_${templateName}`; - const ext = path.extname(originalName); - const uniqueName = uuidv4() + ext; + // 원본 파일명 설정 + const originalFileName = `${tableRowId}_${templateName}`; - const publicDir = path.join(process.cwd(), "public", "basicContract"); - const relativePath = `/basicContract/signed/${uniqueName}`; - const absolutePath = path.join(publicDir, uniqueName); + // 파일을 Buffer로 변환 const buffer = Buffer.from(await file.arrayBuffer()); - await fs.mkdir(publicDir, { recursive: true }); - await fs.writeFile(absolutePath, buffer); + // saveBuffer 함수를 사용하여 파일 저장 + const saveResult = await saveBuffer({ + buffer: buffer, + fileName: file.name, // 실제 업로드된 파일명 + directory: 'basicContract/signed', // 저장 디렉토리 + originalName: originalFileName, // DB에 저장할 원본명 + userId: undefined // 필요시 사용자 ID 추가 + }); + + // 저장 실패 시 에러 반환 + if (!saveResult.success) { + return NextResponse.json({ + result: false, + error: saveResult.error || '파일 저장에 실패했습니다.' + }, { status: 500 }); + } + // DB 업데이트 await db.transaction(async (tx) => { await tx .update(basicContract) .set({ status: "VENDOR_SIGNED", - fileName: originalName, - filePath: relativePath, + fileName: saveResult.originalName || originalFileName, // 원본 파일명 + filePath: saveResult.publicPath, // 웹 접근 가능한 경로 updatedAt: new Date(), completedAt: new Date() }) @@ -48,7 +58,12 @@ export async function POST(request: NextRequest) { revalidateTag("basic-contract-requests"); revalidateTag("basicContractView-vendor"); - return NextResponse.json({ result: true }); + return NextResponse.json({ + result: true, + filePath: saveResult.publicPath, + fileName: saveResult.fileName + }); + } catch (error) { console.error('서명된 계약서 저장 오류:', error); const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; |
