summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/rfq-last/[id]/compare/page.tsx69
-rw-r--r--app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx3
-rw-r--r--app/[lng]/partners/(partners)/tbe-last/page.tsx88
-rw-r--r--app/[lng]/pdftron-viewer/page.tsx507
-rw-r--r--app/api/contracts/prepare-template/route.ts4
-rw-r--r--app/api/document-reviews/[id]/route.ts138
-rw-r--r--app/api/files/[...path]/route.ts38
-rw-r--r--app/api/partners/rfq-last/[id]/response/route.ts12
-rw-r--r--app/api/partners/tbe/[sessionId]/documents/route.ts275
-rw-r--r--app/api/pdftron-comments/xfdf/count/route.ts171
-rw-r--r--app/api/pdftron-comments/xfdf/route.ts362
-rw-r--r--app/api/tbe/sessions/[sessionId]/vendor-questions/route.ts131
-rw-r--r--app/api/tbe/sessions/[sessionId]/vendor-remarks/route.ts57
-rw-r--r--app/api/upload/signed-contract/route.ts43
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 : "알 수 없는 오류";