summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-15 14:41:01 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-15 14:41:01 +0000
commit4ee8b24cfadf47452807fa2af801385ed60ab47c (patch)
treee1d1fb029f0cf5519c517494bf9a545505c35700
parent265859d691a01cdcaaf9154f93c38765bc34df06 (diff)
(대표님) 작업사항 - rfqLast, tbeLast, pdfTron, userAuth
-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
-rw-r--r--config/menuConfig.ts6
-rw-r--r--db/schema/rfqLastTBE.ts211
-rw-r--r--db/schema/rfqVendor.ts224
-rw-r--r--i18n/locales/en/menu.json8
-rw-r--r--i18n/locales/ko/menu.json8
-rw-r--r--lib/admin-users/service.ts2
-rw-r--r--lib/basic-contract/gen-service.ts33
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx1
-rw-r--r--lib/bidding/pre-quote/service.ts49
-rw-r--r--lib/export-to-excel.ts316
-rw-r--r--lib/rfq-last/attachment/vendor-response-table.tsx387
-rw-r--r--lib/rfq-last/compare-action.ts500
-rw-r--r--lib/rfq-last/quotation-compare-view.tsx755
-rw-r--r--lib/rfq-last/service.ts376
-rw-r--r--lib/rfq-last/table/rfq-seal-toggle-cell.tsx93
-rw-r--r--lib/rfq-last/table/rfq-table-columns.tsx73
-rw-r--r--lib/rfq-last/table/rfq-table-toolbar-actions.tsx222
-rw-r--r--lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx335
-rw-r--r--lib/rfq-last/vendor-response/service.ts175
-rw-r--r--lib/rfq-last/vendor-response/validations.ts4
-rw-r--r--lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx2
-rw-r--r--lib/rfq-last/vendor-response/vendor-quotations-table.tsx2
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx223
-rw-r--r--lib/soap/ecc/send/pcr-confirm.ts2
-rw-r--r--lib/tbe-last/service.ts231
-rw-r--r--lib/tbe-last/table/documents-sheet.tsx543
-rw-r--r--lib/tbe-last/table/evaluation-dialog.tsx432
-rw-r--r--lib/tbe-last/table/pr-items-dialog.tsx83
-rw-r--r--lib/tbe-last/table/session-detail-dialog.tsx103
-rw-r--r--lib/tbe-last/table/tbe-last-table-columns.tsx214
-rw-r--r--lib/tbe-last/table/tbe-last-table.tsx279
-rw-r--r--lib/tbe-last/vendor-tbe-service.ts355
-rw-r--r--lib/tbe-last/vendor/tbe-table-columns.tsx335
-rw-r--r--lib/tbe-last/vendor/tbe-table.tsx222
-rw-r--r--lib/tbe-last/vendor/vendor-comment-dialog.tsx313
-rw-r--r--lib/tbe-last/vendor/vendor-document-upload-dialog.tsx326
-rw-r--r--lib/tbe-last/vendor/vendor-documents-sheet.tsx602
-rw-r--r--lib/tbe-last/vendor/vendor-evaluation-view-dialog.tsx250
-rw-r--r--lib/tbe-last/vendor/vendor-pr-items-dialog.tsx253
-rw-r--r--lib/users/auth/verifyCredentails.ts4
-rw-r--r--lib/users/repository.ts7
55 files changed, 9342 insertions, 1115 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 : "알 수 없는 오류";
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index 4e468347..6a726d49 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -937,6 +937,12 @@ export const mainNavVendor: MenuSection[] = [
useGrouping: true,
items: [
{
+ titleKey: "menu.vendor.engineering.tbe",
+ href: `/partners/tbe-last`,
+ descriptionKey: "menu.vendor.engineering.tbe_desc",
+ // groupKey: "groups.shipbuilding",
+ },
+ {
titleKey: "menu.vendor.engineering.data_input_ship",
href: `/partners/vendor-data`,
descriptionKey: "menu.vendor.engineering.data_input_ship_desc",
diff --git a/db/schema/rfqLastTBE.ts b/db/schema/rfqLastTBE.ts
index ba7e30b5..1efb43bb 100644
--- a/db/schema/rfqLastTBE.ts
+++ b/db/schema/rfqLastTBE.ts
@@ -40,7 +40,7 @@ export const rfqLastTbeSessions = pgTable(
// 평가 결과 (단순화)
evaluationResult: varchar("evaluation_result", { length: 30 })
- .$type<"pass" | "conditional_pass" | "non_pass" | null>(),
+ .$type<"Acceptable" | "Acceptable with Comment" | "Not Acceptable" | null>(),
// 조건부 승인 시 조건
conditionalRequirements: text("conditional_requirements"),
@@ -123,7 +123,7 @@ export const rfqLastTbeDocumentReviews = pgTable(
// 벤더 문서인 경우
vendorAttachmentId: integer("vendor_attachment_id")
- .references(() => rfqLastVendorAttachments.id, { onDelete: "cascade" }),
+ .references(() => rfqLastTbeVendorDocuments.id, { onDelete: "cascade" }),
// 검토 정보
documentType: varchar("document_type", { length: 50 }),
@@ -169,6 +169,7 @@ export const rfqLastTbeDocumentReviews = pgTable(
// ==========================================
// 3. PDFTron 코멘트 관리
// ==========================================
+// 수정된 스키마 (버전 관리 제거)
export const rfqLastTbePdftronComments = pgTable(
"rfq_last_tbe_pdftron_comments",
{
@@ -177,65 +178,80 @@ export const rfqLastTbePdftronComments = pgTable(
.notNull()
.references(() => rfqLastTbeDocumentReviews.id, { onDelete: "cascade" }),
- // PDFTron 관련 정보
+ // PDFTron 문서 식별자
pdftronDocumentId: varchar("pdftron_document_id", { length: 255 }).notNull(),
- pdftronAnnotationId: varchar("pdftron_annotation_id", { length: 255 }).notNull(),
- annotationType: varchar("annotation_type", { length: 50 }), // highlight, note, drawing, etc.
- // 위치 정보
- pageNumber: integer("page_number"),
- xPosition: numeric("x_position", { precision: 10, scale: 4 }).$type<number>(),
- yPosition: numeric("y_position", { precision: 10, scale: 4 }).$type<number>(),
- coordinates: jsonb("coordinates"), // 복잡한 도형의 경우
-
- // 코멘트 내용
- commentText: text("comment_text"),
- commentCategory: varchar("comment_category", { length: 50 })
- .$type<"technical" | "commercial" | "quality" | "compliance" | "general">(),
-
- severity: varchar("severity", { length: 20 })
- .$type<"minor" | "major" | "critical">()
- .default("minor"),
-
- // 상태 관리
- status: varchar("status", { length: 30 })
- .$type<"open" | "resolved" | "rejected" | "deferred">()
- .default("open"),
-
- // 해결 정보
- resolvedBy: integer("resolved_by")
- .references(() => users.id, { onDelete: "set null" }),
- resolvedAt: timestamp("resolved_at", { withTimezone: true }).$type<Date | null>(),
- resolutionNote: text("resolution_note"),
-
- // 답변 스레드
- replies: jsonb("replies").$type<{
- userId: number;
- userName: string;
- message: string;
- createdAt: string;
- }[]>(),
+ // XFDF XML 전체 저장 (모든 annotation 포함)
+ xfdfString: text("xfdf_string").notNull(),
+
+ // 파싱된 annotation 데이터 (검색/필터링용)
+ annotationData: jsonb("annotation_data").$type<{
+ annotations: {
+ id: string;
+ type: string;
+ page: number;
+ author: string;
+ subject: string;
+ contents?: string;
+ color?: string;
+ opacity?: number;
+ createdDate: string;
+ modifiedDate?: string;
+ customData?: {
+ category?: "technical" | "commercial" | "quality" | "compliance" | "general";
+ severity?: "minor" | "major" | "critical";
+ status?: "open" | "resolved" | "rejected" | "deferred";
+ createdBy?: number;
+ createdByType?: "buyer" | "vendor";
+ resolvedBy?: number;
+ resolvedAt?: string;
+ resolutionNote?: string;
+ };
+ replies?: {
+ author: string;
+ contents: string;
+ createdDate: string;
+ }[];
+ coords?: number[]; // 좌표 데이터
+ }[];
+ }>(),
+
+ // 요약 정보 (빠른 조회용)
+ commentSummary: jsonb("comment_summary").$type<{
+ total: number;
+ open: number;
+ resolved: number;
+ rejected: number;
+ deferred: number;
+ byCategory: Record<string, number>;
+ bySeverity: Record<string, number>;
+ byAuthor: Record<string, number>;
+ }>(),
// 작성자 정보
createdBy: integer("created_by")
.notNull()
- .references(() => users.id, { onDelete: "set null" }),
+ .references(() => users.id, { onDelete: "restrict" }),
createdByType: varchar("created_by_type", { length: 20 })
.$type<"buyer" | "vendor">()
.notNull(),
+ // 마지막 수정자
+ lastModifiedBy: integer("last_modified_by")
+ .references(() => users.id, { onDelete: "set null" }),
+
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => ({
documentReviewIdx: index("idx_pdftron_doc_review").on(table.documentReviewId),
- statusIdx: index("idx_pdftron_status").on(table.status),
- // PDFTron ID들에 대한 유니크 제약
- uniquePdftronAnnotation: uniqueIndex("unique_pdftron_annotation")
- .on(table.pdftronDocumentId, table.pdftronAnnotationId),
- })
-);
+ documentIdIdx: index("idx_pdftron_doc_id").on(table.pdftronDocumentId),
+ // documentReviewId와 pdftronDocumentId 조합 유니크
+ uniqueDocument: uniqueIndex("unique_document")
+ .on(table.documentReviewId, table.pdftronDocumentId),
+ })
+)
// ==========================================
// 4. TBE 새로운 벤더 첨부파일 (TBE 중 추가 제출)
// ==========================================
@@ -478,6 +494,9 @@ export const tbeLastView = pgView("tbe_last_view").as((qb) => {
series: sql<string | null>`${rfqsLast.series}`.as("series"),
rfqStatus: sql<string>`${rfqsLast.status}`.as("rfq_status"),
rfqDueDate: sql<Date | null>`${rfqsLast.dueDate}`.as("rfq_due_date"),
+ picName: sql<string>`${rfqsLast.picName}`.as("pic_name"),
+ EngPicName: sql<string>`${rfqsLast.EngPicName}`.as("eng_pic_name"),
+
// 패키지 정보
packageNo: sql<string | null>`${rfqsLast.packageNo}`.as("package_no"),
@@ -591,68 +610,74 @@ export const tbeLastView = pgView("tbe_last_view").as((qb) => {
// TBE 문서 상세 뷰 (구매자 + 벤더 문서 통합)
// ==========================================
export const tbeDocumentsView = pgView("tbe_documents_view").as((qb) => {
- const ba = alias(rfqLastAttachments, "ba");
- const baRev = alias(rfqLastAttachmentRevisions, "ba_rev");
+ const dr = alias(rfqLastTbeDocumentReviews, "dr")
+ const ba = alias(rfqLastAttachments, "ba")
+ const baRev = alias(rfqLastAttachmentRevisions, "ba_rev")
+ const vd = alias(rfqLastTbeVendorDocuments, "vd")
+
return qb
.select({
- // 문서 검토 ID
- documentReviewId: sql<number | null>`dr.id`.as("document_review_id"),
- tbeSessionId: sql<number>`COALESCE(dr.tbe_session_id, vd.tbe_session_id)`.as("tbe_session_id"),
-
- // 문서 구분
- documentSource: sql<string>`
- CASE
- WHEN dr.id IS NOT NULL THEN dr.document_source
- WHEN vd.id IS NOT NULL THEN 'vendor'
- ELSE NULL
- END
- `.as("document_source"),
-
- // 문서 정보
- documentId: sql<number>`COALESCE(dr.buyer_attachment_id, vd.id)`.as("document_id"),
- documentType: sql<string | null>`COALESCE(dr.document_type, vd.document_type)`.as("document_type"),
- documentName: sql<string>`COALESCE(dr.document_name, vd.file_name)`.as("document_name"),
+ // 기본키/세션
+ documentReviewId: sql<number>`dr.id`.as("document_review_id"),
+ tbeSessionId: sql<number>`dr.tbe_session_id`.as("tbe_session_id"),
+
+ // 소스
+ documentSource: sql<"buyer" | "vendor">`dr.document_source`.as("document_source"),
+
+ // 문서 식별자: buyer면 buyerAttachmentId, vendor면 vendorAttachmentId
+ documentId: sql<number | null>`
+ CASE
+ WHEN dr.document_source = 'buyer' THEN dr.buyer_attachment_id
+ WHEN dr.document_source = 'vendor' THEN dr.vendor_attachment_id
+ ELSE NULL
+ END
+ `.as("document_id"),
+
+ // 표시 정보
+ documentType: sql<string | null>`dr.document_type`.as("document_type"),
+ documentName: sql<string | null>`dr.document_name`.as("document_name"),
+
+ // 파일 메타: buyer면 ba_rev.*, vendor면 vd.*
originalFileName: sql<string | null>`COALESCE(ba_rev.original_file_name, vd.original_file_name)`.as("original_file_name"),
- filePath: sql<string | null>`COALESCE(ba_rev.file_path, vd.file_path)`.as("file_path"),
- fileSize: sql<number | null>`COALESCE(ba_rev.file_size, vd.file_size)`.as("file_size"),
- fileType: sql<string | null>`COALESCE(ba_rev.file_type, vd.file_type)`.as("file_type"),
+ filePath: sql<string | null>`COALESCE(ba_rev.file_path, vd.file_path)`.as("file_path"),
+ fileSize: sql<number | null>`COALESCE(ba_rev.file_size, vd.file_size)`.as("file_size"),
+ fileType: sql<string | null>`COALESCE(ba_rev.file_type, vd.file_type)`.as("file_type"),
- // 검토 상태
- reviewStatus: sql<string>`COALESCE(dr.review_status, vd.review_status, '미검토')`.as("review_status"),
+ // 리뷰 상태/정보 (dr 기준)
+ reviewStatus: sql<string>`dr.review_status`.as("review_status"),
technicalCompliance: sql<boolean | null>`dr.technical_compliance`.as("technical_compliance"),
qualityAcceptable: sql<boolean | null>`dr.quality_acceptable`.as("quality_acceptable"),
requiresRevision: sql<boolean>`COALESCE(dr.requires_revision, false)`.as("requires_revision"),
- // PDFTron 관련
+ // PDFTron
hasPdftronComments: sql<boolean>`COALESCE(dr.has_pdftron_comments, false)`.as("has_pdftron_comments"),
pdftronDocumentId: sql<string | null>`dr.pdftron_document_id`.as("pdftron_document_id"),
pdftronAnnotationCount: sql<number>`COALESCE(dr.pdftron_annotation_count, 0)`.as("pdftron_annotation_count"),
- // 검토 정보
- reviewedBy: sql<number | null>`COALESCE(dr.reviewed_by, vd.reviewed_by)`.as("reviewed_by"),
- reviewedAt: sql<Date | null>`COALESCE(dr.reviewed_at, vd.reviewed_at)`.as("reviewed_at"),
- reviewComments: sql<string | null>`COALESCE(dr.review_comments, vd.review_comments)`.as("review_comments"),
-
- // 제출 정보 (벤더 문서인 경우)
- submittedBy: sql<number | null>`vd.submitted_by`.as("submitted_by"),
- submittedAt: sql<Date | null>`vd.submitted_at`.as("submitted_at"),
-
- // 타임스탬프
- createdAt: sql<Date>`COALESCE(dr.created_at, vd.submitted_at)`.as("created_at"),
- updatedAt: sql<Date>`COALESCE(dr.updated_at, vd.submitted_at)`.as("updated_at"),
+ // 검토자/타임스탬프
+ reviewedBy: sql<number | null>`dr.reviewed_by`.as("reviewed_by"),
+ reviewedAt: sql<Date | null>`dr.reviewed_at`.as("reviewed_at"),
+ reviewComments: sql<string | null>`dr.review_comments`.as("review_comments"),
+
+ // 제출 정보(벤더 문서일 때 vd의 제출 정보 노출, 아니면 null)
+ submittedBy: sql<number | null>`
+ CASE WHEN dr.document_source = 'vendor' THEN vd.submitted_by ELSE NULL END
+ `.as("submitted_by"),
+ submittedAt: sql<Date | null>`
+ CASE WHEN dr.document_source = 'vendor' THEN vd.submitted_at ELSE NULL END
+ `.as("submitted_at"),
+
+ // 생성/업데이트 시각: 리뷰기준
+ createdAt: sql<Date>`dr.created_at`.as("created_at"),
+ updatedAt: sql<Date>`dr.updated_at`.as("updated_at"),
})
- .from(
- sql`(
- SELECT * FROM rfq_last_tbe_document_reviews
- ) dr
- FULL OUTER JOIN (
- SELECT * FROM rfq_last_tbe_vendor_documents
- ) vd ON false
- `
- )
+ .from(dr)
+ // buyer: 리뷰가 가리키는 첨부 개정에 조인
.leftJoin(ba, sql`dr.buyer_attachment_id = ${ba.id}`)
- .leftJoin(baRev, sql`dr.buyer_attachment_revision_id = ${baRev.id}`);
-});
+ .leftJoin(baRev, sql`dr.buyer_attachment_revision_id = ${baRev.id}`)
+ // vendor: 리뷰가 가리키는 벤더 문서에 조인
+ .leftJoin(vd, sql`dr.vendor_attachment_id = ${vd.id}`)
+})
// Type exports
export type TbeLastView = typeof tbeLastView.$inferSelect;
diff --git a/db/schema/rfqVendor.ts b/db/schema/rfqVendor.ts
index 5752b1c2..0ddf109b 100644
--- a/db/schema/rfqVendor.ts
+++ b/db/schema/rfqVendor.ts
@@ -1,9 +1,10 @@
import { pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, jsonb } from "drizzle-orm/pg-core";
-import { eq, sql, relations } from "drizzle-orm";
+import { eq, sql, relations,and } from "drizzle-orm";
import { rfqsLast, rfqLastDetails, rfqPrItems } from "./rfqLast";
import { users } from "./users";
import { vendors } from "./vendors";
import { incoterms, paymentTerms } from "./procurementRFQ";
+import { projects } from "./projects";
// ==========================================
// 1. 벤더 응답 메인 테이블 (견적서 헤더)
@@ -458,4 +459,223 @@ export const vendorQuotationItemsRelations = relations(
export type VendorResponse = typeof rfqLastVendorResponses.$inferSelect;
export type VendorQuotationItem = typeof rfqLastVendorQuotationItems.$inferSelect;
export type VendorAttachment = typeof rfqLastVendorAttachments.$inferSelect;
-export type VendorResponseHistory = typeof rfqLastVendorResponseHistory.$inferSelect; \ No newline at end of file
+export type VendorResponseHistory = typeof rfqLastVendorResponseHistory.$inferSelect;
+
+
+// vendorQuotationView - 벤더별 견적 현황을 보여주는 통합 뷰
+export const vendorQuotationView = pgView("vendor_quotation_view").as((qb) => {
+ const createdByUser = alias(users, "created_by_user");
+ const updatedByUser = alias(users, "updated_by_user");
+ const sentByUser = alias(users, "sent_by_user");
+ const picUser = alias(users, "pic_user");
+
+ return qb
+ .select({
+ // ===== RFQ 기본 정보 (rfqsLastView에서 가져온 필드들) =====
+ id: sql<number>`${rfqsLast.id}`.as("id"),
+ rfqCode: sql<string>`${rfqsLast.rfqCode}`.as("rfq_code"),
+ series: sql<string | null>`${rfqsLast.series}`.as("series"),
+ rfqSealedYn: sql<boolean | null>`${rfqsLast.rfqSealedYn}`.as("rfq_sealed_yn"),
+
+ // RFQ 타입 정보
+ rfqType: sql<string | null>`${rfqsLast.rfqType}`.as("rfq_type"),
+ rfqTitle: sql<string | null>`${rfqsLast.rfqTitle}`.as("rfq_title"),
+
+ // ITB 관련 필드
+ projectCompany: sql<string | null>`${rfqsLast.projectCompany}`.as("project_company"),
+ projectFlag: sql<string | null>`${rfqsLast.projectFlag}`.as("project_flag"),
+ projectSite: sql<string | null>`${rfqsLast.projectSite}`.as("project_site"),
+ smCode: sql<string | null>`${rfqsLast.smCode}`.as("sm_code"),
+
+ // RFQ 추가 필드
+ prNumber: sql<string | null>`${rfqsLast.prNumber}`.as("pr_number"),
+ prIssueDate: sql<Date | null>`${rfqsLast.prIssueDate}`.as("pr_issue_date"),
+
+ // 프로젝트 정보
+ projectId: sql<number | null>`${rfqsLast.projectId}`.as("project_id"),
+ projectCode: sql<string | null>`${projects.code}`.as("project_code"),
+ projectName: sql<string | null>`${projects.name}`.as("project_name"),
+
+ // 아이템 정보
+ itemCode: sql<string | null>`${rfqsLast.itemCode}`.as("item_code"),
+ itemName: sql<string | null>`${rfqsLast.itemName}`.as("item_name"),
+
+ // 패키지 정보
+ packageNo: sql<string | null>`${rfqsLast.packageNo}`.as("package_no"),
+ packageName: sql<string | null>`${rfqsLast.packageName}`.as("package_name"),
+
+ engPicName: sql<string | null>`${rfqsLast.EngPicName}`.as("eng_pic_name"),
+
+ // 상태와 날짜
+ status: sql<string>`${rfqsLast.status}`.as("status"),
+ rfqSendDate: sql<Date | null>`${rfqsLast.rfqSendDate}`.as("rfq_send_date"),
+ dueDate: sql<Date | null>`${rfqsLast.dueDate}`.as("due_date"),
+
+ // PIC 정보
+ picId: sql<number | null>`${rfqsLast.pic}`.as("pic_id"),
+ picCode: sql<string | null>`${rfqsLast.picCode}`.as("pic_code"),
+ picName: sql<string | null>`${rfqsLast.picName}`.as("pic_name"),
+ picUserName: sql<string | null>`${picUser.name}`.as("pic_user_name"),
+
+ // 감사 정보
+ createdBy: sql<number>`${rfqsLast.createdBy}`.as("created_by"),
+ createdByUserName: sql<string | null>`${createdByUser.name}`.as("created_by_user_name"),
+ createdAt: sql<Date>`${rfqsLast.createdAt}`.as("created_at"),
+ sentBy: sql<number | null>`${rfqsLast.sentBy}`.as("sent_by"),
+ sentByUserName: sql<string | null>`${sentByUser.name}`.as("sent_by_user_name"),
+ updatedBy: sql<number>`${rfqsLast.updatedBy}`.as("updated_by"),
+ updatedByUserName: sql<string | null>`${updatedByUser.name}`.as("updated_by_user_name"),
+ updatedAt: sql<Date>`${rfqsLast.updatedAt}`.as("updated_at"),
+ remark: sql<string | null>`${rfqsLast.remark}`.as("remark"),
+
+ // ===== 벤더별 정보 =====
+ vendorId: sql<number | null>`${vendors.id}`.as("vendor_id"),
+ vendorName: sql<string | null>`${vendors.vendorName}`.as("vendor_name"),
+ vendorCode: sql<string | null>`${vendors.vendorCode}`.as("vendor_code"),
+
+ // rfqLastDetails 정보
+ rfqLastDetailsId: sql<number | null>`${rfqLastDetails.id}`.as("rfq_last_details_id"),
+ emailSentAt: sql<Date | null>`${rfqLastDetails.emailSentAt}`.as("email_sent_at"),
+ emailStatus: sql<string | null>`${rfqLastDetails.emailStatus}`.as("email_status"),
+ shortList: sql<boolean>`${rfqLastDetails.shortList}`.as("short_list"),
+
+ // ===== 벤더 응답 정보 (rfqLastVendorResponses) =====
+ vendorResponseId: sql<number | null>`${rfqLastVendorResponses.id}`.as("vendor_response_id"),
+
+ // 참여 상태
+ participationStatus: sql<string | null>`${rfqLastVendorResponses.participationStatus}`.as("participation_status"),
+ participationRepliedAt: sql<Date | null>`${rfqLastVendorResponses.participationRepliedAt}`.as("participation_replied_at"),
+ nonParticipationReason: sql<string | null>`${rfqLastVendorResponses.nonParticipationReason}`.as("non_participation_reason"),
+
+ // 응답 상태
+ responseStatus: sql<string | null>`${rfqLastVendorResponses.status}`.as("response_status"),
+ responseVersion: sql<number | null>`${rfqLastVendorResponses.responseVersion}`.as("response_version"),
+ submittedAt: sql<Date | null>`${rfqLastVendorResponses.submittedAt}`.as("submitted_at"),
+
+ // 금액 정보
+ totalAmount: sql<number | null>`${rfqLastVendorResponses.totalAmount}`.as("total_amount"),
+ vendorCurrency: sql<string | null>`${rfqLastVendorResponses.vendorCurrency}`.as("vendor_currency"),
+
+ // 벤더 제안 조건
+ vendorPaymentTermsCode: sql<string | null>`${rfqLastVendorResponses.vendorPaymentTermsCode}`.as("vendor_payment_terms_code"),
+ vendorIncotermsCode: sql<string | null>`${rfqLastVendorResponses.vendorIncotermsCode}`.as("vendor_incoterms_code"),
+ vendorDeliveryDate: sql<Date | null>`${rfqLastVendorResponses.vendorDeliveryDate}`.as("vendor_delivery_date"),
+
+ // ===== 계산된 필드 - displayStatus =====
+ displayStatus: sql<string | null>`
+ CASE
+ WHEN ${rfqLastVendorResponses.participationStatus} = '불참' THEN '불참'
+ WHEN ${rfqLastVendorResponses.participationStatus} = '참여' THEN
+ COALESCE(${rfqLastVendorResponses.status}, '작성중')
+ WHEN ${rfqLastVendorResponses.participationStatus} = '미응답' OR ${rfqLastVendorResponses.participationStatus} IS NULL THEN
+ CASE
+ WHEN ${rfqLastDetails.emailSentAt} IS NOT NULL THEN '미응답'
+ ELSE NULL
+ END
+ ELSE '미응답'
+ END
+ `.as("display_status"),
+
+ // ===== 집계 정보 (RFQ 레벨) =====
+ vendorCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM rfq_last_details d
+ WHERE d.rfqs_last_id = ${rfqsLast.id}
+ AND d.is_latest = true
+ )`.as("vendor_count"),
+
+ shortListedVendorCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM rfq_last_details d
+ WHERE d.rfqs_last_id = ${rfqsLast.id}
+ AND d.short_list = true
+ AND d.is_latest = true
+ )`.as("short_listed_vendor_count"),
+
+ quotationReceivedCount: sql<number>`(
+ SELECT COUNT(DISTINCT r.vendor_id)
+ FROM rfq_last_vendor_responses r
+ WHERE r.rfqs_last_id = ${rfqsLast.id}
+ AND r.submitted_at IS NOT NULL
+ AND r.is_latest = true
+ )`.as("quotation_received_count"),
+
+ earliestQuotationSubmittedAt: sql<Date | null>`(
+ SELECT MIN(r.submitted_at)
+ FROM rfq_last_vendor_responses r
+ WHERE r.rfqs_last_id = ${rfqsLast.id}
+ AND r.submitted_at IS NOT NULL
+ AND r.is_latest = true
+ )`.as("earliest_quotation_submitted_at"),
+
+ // PR Items 관련 정보
+ majorItemMaterialCode: sql<string | null>`(
+ SELECT material_code
+ FROM rfq_pr_items
+ WHERE rfqs_last_id = ${rfqsLast.id}
+ AND major_yn = true
+ LIMIT 1
+ )`.as("major_item_material_code"),
+
+ majorItemMaterialDescription: sql<string | null>`(
+ SELECT material_description
+ FROM rfq_pr_items
+ WHERE rfqs_last_id = ${rfqsLast.id}
+ AND major_yn = true
+ LIMIT 1
+ )`.as("major_item_material_description"),
+
+ majorItemMaterialCategory: sql<string | null>`(
+ SELECT material_category
+ FROM rfq_pr_items
+ WHERE rfqs_last_id = ${rfqsLast.id}
+ AND major_yn = true
+ LIMIT 1
+ )`.as("major_item_material_category"),
+
+ majorItemPrNo: sql<string | null>`(
+ SELECT pr_no
+ FROM rfq_pr_items
+ WHERE rfqs_last_id = ${rfqsLast.id}
+ AND major_yn = true
+ LIMIT 1
+ )`.as("major_item_pr_no"),
+
+ prItemsCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM rfq_pr_items
+ WHERE rfqs_last_id = ${rfqsLast.id}
+ )`.as("pr_items_count"),
+
+ majorItemsCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM rfq_pr_items
+ WHERE rfqs_last_id = ${rfqsLast.id}
+ AND major_yn = true
+ )`.as("major_items_count")
+ })
+ .from(rfqsLast)
+ .innerJoin(rfqLastDetails,
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqsLast.id),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ )
+ .leftJoin(vendors, eq(rfqLastDetails.vendorsId, vendors.id))
+ .leftJoin(rfqLastVendorResponses,
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqsLast.id),
+ eq(rfqLastVendorResponses.vendorId, vendors.id),
+ eq(rfqLastVendorResponses.isLatest, true)
+ )
+ )
+ .leftJoin(projects, eq(rfqsLast.projectId, projects.id))
+ .leftJoin(createdByUser, eq(rfqsLast.createdBy, createdByUser.id))
+ .leftJoin(updatedByUser, eq(rfqsLast.updatedBy, updatedByUser.id))
+ .leftJoin(sentByUser, eq(rfqsLast.sentBy, sentByUser.id))
+ .leftJoin(picUser, eq(rfqsLast.pic, picUser.id));
+});
+
+// Type export
+export type VendorQuotationView = typeof vendorQuotationView.$inferSelect;
+
diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json
index fdd056f4..402bf1ae 100644
--- a/i18n/locales/en/menu.json
+++ b/i18n/locales/en/menu.json
@@ -160,10 +160,12 @@
"tbe_ship_desc": "History management of TBE and vendor responses",
"tbe_plant": "Technical (Quality) Evaluation (TBE) Offshore",
"tbe_plant_desc": "History management of TBE generated from S-EDP and vendor responses",
- "po_issuance": "PO Issuance",
+ "po_issuance": "PO/Contract Management",
"po_issuance_desc": "PO (Purchase Order) confirmation/signature request/contract details storage",
"po_amendment": "PO Amendment Issuance",
"po_amendment_desc": "Amendment PO (Purchase Order) creation/signature request/contract details storage",
+ "pcr": "PCR",
+ "pcr_desc": "Purchase Change Request management",
"general_contract": "General Contract",
"general_contract_desc": "General contract management"
},
@@ -224,12 +226,16 @@
"po_desc": "Order list confirmation and electronic signature",
"po_amendment": "PO Amendment",
"po_amendment_desc": "Order list confirmation and electronic signature",
+ "pcr": "PCR",
+ "pcr_desc": "Purchase Change Request management",
"general_contract": "General Contract",
"general_contract_desc": "Order list confirmation and electronic signature",
"rfq_response":"견적 응답",
"rfq_response_desc":"견적 요청에 대한 응답 작성"
},
"engineering": {
+ "tbe": "TBE",
+ "tbe_desc": "Technical Bid Evaluation",
"title": "Engineering",
"data_input_ship": "Data Input",
"data_input_ship_desc": "Vendor data input based on reference information",
diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json
index e9e1b87f..d6c3d340 100644
--- a/i18n/locales/ko/menu.json
+++ b/i18n/locales/ko/menu.json
@@ -164,10 +164,12 @@
"tbe_ship_desc": "TBE와 업체의 응답에 대한 이력 관리",
"tbe_plant": "기술(품질) 평가 (TBE) 해양",
"tbe_plant_desc": "S-EDP로부터 생성된 TBE와 업체의 응답에 대한 이력 관리",
- "po_issuance": "PO 발행",
+ "po_issuance": "PO/계약 관리",
"po_issuance_desc": "PO(구매 발주서) 확인/서명 요청/계약 내역 저장",
"po_amendment": "변경 PO 발행",
"po_amendment_desc": "변경 PO(구매 발주서) 생성/서명 요청/계약 내역 저장",
+ "pcr": "PCR",
+ "pcr_desc": "PCR 관리",
"general_contract": "일반 계약",
"general_contract_desc": "일반 계약 관리"
},
@@ -227,6 +229,8 @@
"po_desc": "발주 리스트 확인 및 전자서명",
"po_amendment": "PO Amendment",
"po_amendment_desc": "발주 리스트 확인 및 전자서명",
+ "pcr": "PCR",
+ "pcr_desc": "PCR 관리",
"general_contract": "일반 계약",
"general_contract_desc": "발주 리스트 확인 및 전자서명",
"rfq_response":"견적 응답",
@@ -234,6 +238,8 @@
},
"engineering": {
"title": "Engineering",
+ "tbe": "TBE",
+ "tbe_desc": "Technical Bid Evaluation",
"data_input_ship": "데이터 입력",
"data_input_ship_desc": "기준 정보에 입각한 협력업체 데이터 입력",
"document_list_ship": "문서/도서 리스트 및 제출(조선)",
diff --git a/lib/admin-users/service.ts b/lib/admin-users/service.ts
index c253f481..70c04aa1 100644
--- a/lib/admin-users/service.ts
+++ b/lib/admin-users/service.ts
@@ -262,7 +262,7 @@ export async function createAdminUser(input: CreateUserSchema & { language?: str
// 3. 유저 생성
const [newUser] = await insertUser(tx, {
name: input.name,
- email: input.email,
+ email: input.email.toLowerCase(),
phone: input.phone?.trim(), // 전화번호 앞뒤 공백 제거
domain: input.domain,
companyId: input.companyId ?? null,
diff --git a/lib/basic-contract/gen-service.ts b/lib/basic-contract/gen-service.ts
index 5619f98e..aa9efbc1 100644
--- a/lib/basic-contract/gen-service.ts
+++ b/lib/basic-contract/gen-service.ts
@@ -8,6 +8,7 @@ import { eq, and, ilike } from "drizzle-orm";
import { addDays } from "date-fns";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
+import { saveBuffer } from '@/lib/file-storage'; // 추가
interface BasicContractParams {
templateName: string;
@@ -348,17 +349,19 @@ export async function saveContractPdf({
templateId: number;
}) {
try {
- // 1. PDF 파일 저장
- const outputDir = path.join(process.cwd(), process.env.NAS_PATH, "contracts", "generated");
- await mkdir(outputDir, { recursive: true });
+ // 1. PDF 파일 저장 (공용 saveBuffer 사용)
+ const saveResult = await saveBuffer({
+ buffer: Buffer.from(pdfBuffer),
+ fileName: fileName, // 원본 파일명 (확장자 포함)
+ directory: 'contracts/generated',
+ originalName: fileName, // DB에 저장할 원본명
+ userId: params.requestedBy // 요청자 ID를 userId로 사용
+ });
- const timestamp = Date.now();
- const finalFileName = `${fileName.replace('.pdf', '')}_${timestamp}.pdf`;
- const outputPath = path.join(outputDir, finalFileName);
-
- await writeFile(outputPath, Buffer.from(pdfBuffer));
-
- const relativePath = `/contracts/generated/${finalFileName}`;
+ // 저장 실패 시 에러 처리
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || 'PDF 파일 저장에 실패했습니다.');
+ }
// 2. DB에 계약서 레코드 생성
const [newContract] = await db
@@ -371,8 +374,8 @@ export async function saveContractPdf({
generalContractId: params.generalContractId,
requestedBy: params.requestedBy,
status: "PENDING",
- fileName: finalFileName,
- filePath: relativePath,
+ fileName: saveResult.originalName || fileName, // 원본 파일명
+ filePath: saveResult.publicPath, // 웹 접근 가능한 경로
deadline: addDays(new Date(), 10),
createdAt: new Date(),
updatedAt: new Date(),
@@ -385,8 +388,10 @@ export async function saveContractPdf({
templateName: params.templateName,
status: newContract.status,
deadline: newContract.deadline,
- pdfPath: relativePath,
- pdfFileName: finalFileName
+ pdfPath: saveResult.publicPath, // 공용 함수가 반환한 경로
+ pdfFileName: saveResult.originalName || fileName, // 원본 파일명
+ hashedFileName: saveResult.fileName, // 실제 저장된 해시 파일명 (필요시 사용)
+ securityChecks: saveResult.securityChecks // 보안 검증 결과 (디버깅용)
};
} catch (error) {
console.error("PDF 저장 실패:", error);
diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
index e52f0d79..5698428e 100644
--- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
+++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
@@ -594,6 +594,7 @@ export function BasicContractSignViewer({
isComplete: false
});
+ console.log(filePath, "filePath")
console.log(surveyTemplate, "surveyTemplate")
const conditionalHandler = useConditionalSurvey(surveyTemplate);
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 680a8ff5..7f054a66 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -11,7 +11,7 @@ import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
import { revalidateTag, revalidatePath } from 'next/cache'
import { basicContract } from '@/db/schema/basicContractDocumnet'
-import { saveFile } from '@/lib/file-stroage'
+import { saveFile ,saveBuffer} from '@/lib/file-stroage'
// userId를 user.name으로 변환하는 유틸리티 함수
async function getUserNameById(userId: string): Promise<string> {
@@ -1225,10 +1225,7 @@ export async function sendBiddingBasicContracts(
const results = []
const savedContracts = []
- // 트랜잭션 시작
- const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated");
- await mkdir(contractsDir, { recursive: true });
-
+ // 트랜잭션 시작 - contractsDir 제거 (saveBuffer가 처리)
const result = await db.transaction(async (tx) => {
// 각 벤더별로 기본계약 생성 및 이메일 발송
for (const vendor of vendorData) {
@@ -1288,6 +1285,7 @@ export async function sendBiddingBasicContracts(
if (vendor.contractRequirements.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' })
if (vendor.contractRequirements.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' })
console.log("contractTypes", contractTypes)
+
for (const contractType of contractTypes) {
// PDF 데이터 찾기 (include를 사용하여 유연하게 찾기)
console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key))
@@ -1301,11 +1299,22 @@ export async function sendBiddingBasicContracts(
continue
}
- // 파일 저장 (rfq-last 방식)
+ // 파일 저장 - saveBuffer 사용
const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf`
- const filePath = path.join(contractsDir, fileName);
+
+ const saveResult = await saveBuffer({
+ buffer: Buffer.from(pdfData.buffer),
+ fileName: fileName,
+ directory: 'contracts/generated',
+ originalName: fileName,
+ userId: currentUser.id
+ })
- await writeFile(filePath, Buffer.from(pdfData.buffer));
+ // 저장 실패 시 처리
+ if (!saveResult.success) {
+ console.error(`PDF 저장 실패: ${saveResult.error}`)
+ continue
+ }
// 템플릿 정보 조회 (rfq-last 방식)
const [template] = await db
@@ -1343,8 +1352,8 @@ export async function sendBiddingBasicContracts(
.set({
requestedBy: currentUser.id,
status: "PENDING", // 재발송 상태
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
+ fileName: saveResult.originalName || fileName, // 원본 파일명
+ filePath: saveResult.publicPath, // saveBuffer가 반환한 공개 경로
deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
updatedAt: new Date(),
})
@@ -1364,8 +1373,8 @@ export async function sendBiddingBasicContracts(
generalContractId: null,
requestedBy: currentUser.id,
status: 'PENDING',
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
+ fileName: saveResult.originalName || fileName, // 원본 파일명
+ filePath: saveResult.publicPath, // saveBuffer가 반환한 공개 경로
deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후
createdAt: new Date(),
updatedAt: new Date(),
@@ -1380,19 +1389,10 @@ export async function sendBiddingBasicContracts(
vendorName: vendor.vendorName,
contractId: contractRecord.id,
contractType: contractType.type,
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
+ fileName: saveResult.originalName || fileName,
+ filePath: saveResult.publicPath,
+ hashedFileName: saveResult.fileName, // 실제 저장된 파일명 (디버깅용)
})
-
- // savedContracts에 추가 (rfq-last 방식)
- // savedContracts.push({
- // vendorId: vendor.vendorId,
- // vendorName: vendor.vendorName,
- // templateName: contractType.templateName,
- // contractId: contractRecord.id,
- // fileName: fileName,
- // isUpdated: !!existingContract, // 업데이트 여부 표시
- // })
}
// 이메일 발송 (선택사항)
@@ -1439,7 +1439,6 @@ export async function sendBiddingBasicContracts(
)
}
}
-
// 기존 기본계약 조회 (서버 액션)
export async function getExistingBasicContractsForBidding(biddingId: number) {
try {
diff --git a/lib/export-to-excel.ts b/lib/export-to-excel.ts
new file mode 100644
index 00000000..b35c18d6
--- /dev/null
+++ b/lib/export-to-excel.ts
@@ -0,0 +1,316 @@
+// lib/utils/export-to-excel.ts
+
+import ExcelJS from 'exceljs'
+
+interface ExportToExcelOptions {
+ filename?: string
+ sheetName?: string
+ headers?: string[]
+ dateFormat?: string
+ autoFilter?: boolean
+ freezeHeader?: boolean
+}
+
+/**
+ * 데이터 배열을 Excel 파일로 내보내기 (ExcelJS 사용)
+ * @param data - 내보낼 데이터 배열
+ * @param options - 내보내기 옵션
+ */
+export async function exportDataToExcel(
+ data: Record<string, any>[],
+ options: ExportToExcelOptions = {}
+) {
+ const {
+ filename = 'export',
+ sheetName = 'Sheet1',
+ headers,
+ dateFormat = 'yyyy-mm-dd',
+ autoFilter = true,
+ freezeHeader = true
+ } = options
+
+ try {
+ // 데이터가 없으면 반환
+ if (!data || data.length === 0) {
+ console.warn('No data to export')
+ return false
+ }
+
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook()
+ workbook.creator = 'TBE System'
+ workbook.created = new Date()
+
+ // 워크시트 추가
+ const worksheet = workbook.addWorksheet(sheetName, {
+ properties: {
+ defaultRowHeight: 20
+ }
+ })
+
+ // 헤더 처리
+ const finalHeaders = headers || Object.keys(data[0])
+
+ // 컬럼 정의
+ const columns = finalHeaders.map(header => ({
+ header,
+ key: header,
+ width: Math.min(
+ Math.max(
+ header.length,
+ ...data.map(row => {
+ const value = row[header]
+ if (value === null || value === undefined) return 0
+ return String(value).length
+ })
+ ) + 2,
+ 50
+ )
+ }))
+
+ worksheet.columns = columns
+
+ // 데이터 추가
+ data.forEach(row => {
+ const rowData: Record<string, any> = {}
+
+ finalHeaders.forEach(header => {
+ const value = row[header]
+
+ // null/undefined 처리
+ if (value === null || value === undefined) {
+ rowData[header] = ''
+ }
+ // Date 객체 처리
+ else if (value instanceof Date) {
+ rowData[header] = value
+ }
+ // boolean 처리
+ else if (typeof value === 'boolean') {
+ rowData[header] = value ? 'Yes' : 'No'
+ }
+ // 숫자 처리
+ else if (typeof value === 'number') {
+ rowData[header] = value
+ }
+ // 기타 (문자열 등)
+ else {
+ rowData[header] = String(value)
+ }
+ })
+
+ worksheet.addRow(rowData)
+ })
+
+ // 헤더 스타일링
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ }
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' }
+ headerRow.height = 25
+
+ // 헤더 테두리
+ headerRow.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ }
+ })
+
+ // 데이터 행 스타일링
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > 1) {
+ row.alignment = { vertical: 'middle' }
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ }
+ })
+ }
+ })
+
+ // 자동 필터 추가
+ if (autoFilter) {
+ worksheet.autoFilter = {
+ from: { row: 1, column: 1 },
+ to: { row: data.length + 1, column: columns.length }
+ }
+ }
+
+ // 헤더 고정
+ if (freezeHeader) {
+ worksheet.views = [
+ { state: 'frozen', ySplit: 1 }
+ ]
+ }
+
+ // 날짜 포맷 적용
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > 1) {
+ row.eachCell((cell, colNumber) => {
+ const header = finalHeaders[colNumber - 1]
+ const value = data[rowNumber - 2][header]
+
+ if (value instanceof Date) {
+ cell.numFmt = dateFormat === 'yyyy-mm-dd'
+ ? 'yyyy-mm-dd'
+ : 'mm/dd/yyyy'
+ }
+ // 숫자 포맷 (천단위 구분)
+ else if (typeof value === 'number' && header.toLowerCase().includes('quantity')) {
+ cell.numFmt = '#,##0'
+ }
+ })
+ }
+ })
+
+ // 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ })
+
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ const timestamp = new Date().toISOString().slice(0, 10)
+ const finalFilename = `${filename}_${timestamp}.xlsx`
+
+ link.href = url
+ link.download = finalFilename
+ link.style.display = 'none'
+
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+
+ URL.revokeObjectURL(url)
+
+ return true
+ } catch (error) {
+ console.error('Excel export error:', error)
+ throw new Error('Failed to export Excel file')
+ }
+}
+
+/**
+ * Date 객체를 Excel 형식으로 포맷팅
+ */
+function formatDateForExcel(date: Date, format: string): string {
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+
+ switch (format) {
+ case 'yyyy-mm-dd':
+ return `${year}-${month}-${day}`
+ case 'dd/mm/yyyy':
+ return `${day}/${month}/${year}`
+ case 'mm/dd/yyyy':
+ return `${month}/${day}/${year}`
+ default:
+ return date.toLocaleDateString()
+ }
+}
+
+/**
+ * CSV 파일로 내보내기 (대안 옵션)
+ */
+export function exportDataToCSV(
+ data: Record<string, any>[],
+ filename: string = 'export'
+) {
+ try {
+ if (!data || data.length === 0) {
+ console.warn('No data to export')
+ return
+ }
+
+ // 헤더 추출
+ const headers = Object.keys(data[0])
+
+ // CSV 문자열 생성
+ let csvContent = headers.join(',') + '\n'
+
+ data.forEach(row => {
+ const values = headers.map(header => {
+ const value = row[header]
+
+ // null/undefined 처리
+ if (value === null || value === undefined) return ''
+
+ // 콤마나 줄바꿈이 있으면 따옴표로 감싸기
+ const stringValue = String(value)
+ if (stringValue.includes(',') || stringValue.includes('\n')) {
+ return `"${stringValue.replace(/"/g, '""')}"`
+ }
+
+ return stringValue
+ })
+
+ csvContent += values.join(',') + '\n'
+ })
+
+ // BOM 추가 (Excel에서 UTF-8 인식)
+ const BOM = '\uFEFF'
+ const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
+
+ // 다운로드 링크 생성
+ const link = document.createElement('a')
+ const url = URL.createObjectURL(blob)
+
+ link.setAttribute('href', url)
+ link.setAttribute('download', `${filename}_${new Date().toISOString().slice(0, 10)}.csv`)
+ link.style.visibility = 'hidden'
+
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+
+ return true
+ } catch (error) {
+ console.error('CSV export error:', error)
+ throw new Error('Failed to export CSV file')
+ }
+}
+
+/**
+ * 간단한 데이터 내보내기 헬퍼
+ * Excel이 안되면 CSV로 fallback
+ */
+export async function exportData(
+ data: Record<string, any>[],
+ options: ExportToExcelOptions & { format?: 'excel' | 'csv' } = {}
+) {
+ const { format = 'excel', ...exportOptions } = options
+
+ try {
+ if (format === 'csv') {
+ return exportDataToCSV(data, exportOptions.filename)
+ } else {
+ return exportDataToExcel(data, exportOptions)
+ }
+ } catch (error) {
+ console.error(`Failed to export as ${format}, trying CSV as fallback`)
+
+ // Excel 실패 시 CSV로 시도
+ if (format === 'excel') {
+ try {
+ return exportDataToCSV(data, exportOptions.filename)
+ } catch (csvError) {
+ console.error('Both Excel and CSV export failed')
+ throw csvError
+ }
+ }
+
+ throw error
+ }
+} \ No newline at end of file
diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx
index 6e1a02c8..f9388752 100644
--- a/lib/rfq-last/attachment/vendor-response-table.tsx
+++ b/lib/rfq-last/attachment/vendor-response-table.tsx
@@ -17,7 +17,7 @@ import {
FileCode,
Building2,
Calendar,
- AlertCircle
+ AlertCircle, X
} from "lucide-react";
import { format, formatDistanceToNow, isValid, isBefore, isAfter } from "date-fns";
import { ko } from "date-fns/locale";
@@ -46,6 +46,22 @@ import { cn } from "@/lib/utils";
import { getRfqVendorAttachments } from "@/lib/rfq-last/service";
import { downloadFile } from "@/lib/file-download";
import { toast } from "sonner";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
// 타입 정의
interface VendorAttachment {
@@ -138,24 +154,79 @@ export function VendorResponseTable({
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [selectedRows, setSelectedRows] = React.useState<VendorAttachment[]>([]);
- // 데이터 새로고침
- const handleRefresh = React.useCallback(async () => {
- setIsRefreshing(true);
+
+
+ const [isUpdating, setIsUpdating] = React.useState(false);
+ const [showTypeDialog, setShowTypeDialog] = React.useState(false);
+ const [selectedType, setSelectedType] = React.useState<"구매" | "설계" | "">("");
+ console.log(data,"data")
+
+ const [selectedVendor, setSelectedVendor] = React.useState<string | null>(null);
+
+ const filteredData = React.useMemo(() => {
+ if (!selectedVendor) return data;
+ return data.filter(item => item.vendorName === selectedVendor);
+ }, [data, selectedVendor]);
+
+
+
+ // 데이터 새로고침
+ const handleRefresh = React.useCallback(async () => {
+ setIsRefreshing(true);
+ try {
+ const result = await getRfqVendorAttachments(rfqId);
+ if (result.vendorSuccess && result.vendorData) {
+ setData(result.vendorData);
+ toast.success("데이터를 새로고침했습니다.");
+ } else {
+ toast.error("데이터를 불러오는데 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("Refresh error:", error);
+ toast.error("새로고침 중 오류가 발생했습니다.");
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [rfqId]);
+
+ const toggleVendorFilter = (vendor: string) => {
+ if (selectedVendor === vendor) {
+ setSelectedVendor(null); // 이미 선택된 벤더를 다시 클릭하면 필터 해제
+ } else {
+ setSelectedVendor(vendor);
+ // 필터 변경 시 선택 초기화 (옵션)
+ setSelectedRows([]);
+ }
+ };
+
+ // 문서 유형 일괄 변경
+ const handleBulkTypeChange = React.useCallback(async () => {
+ if (!selectedType || selectedRows.length === 0) return;
+
+ setIsUpdating(true);
try {
- const result = await getRfqVendorAttachments(rfqId);
- if (result.success && result.data) {
- setData(result.data);
- toast.success("데이터를 새로고침했습니다.");
+ const ids = selectedRows.map(row => row.id);
+ const result = await updateAttachmentTypes(ids, selectedType as "구매" | "설계");
+
+ if (result.success) {
+ toast.success(result.message);
+ // 데이터 새로고침
+ await handleRefresh();
+ // 선택 초기화
+ setSelectedRows([]);
+ setShowTypeDialog(false);
+ setSelectedType("");
} else {
- toast.error("데이터를 불러오는데 실패했습니다.");
+ toast.error(result.message);
}
} catch (error) {
- console.error("Refresh error:", error);
- toast.error("새로고침 중 오류가 발생했습니다.");
+ toast.error("문서 유형 변경 중 오류가 발생했습니다.");
} finally {
- setIsRefreshing(false);
+ setIsUpdating(false);
}
- }, [rfqId]);
+ }, [selectedType, selectedRows, handleRefresh]);
+
+
// 액션 처리
const handleAction = React.useCallback(async (action: DataTableRowAction<VendorAttachment>) => {
@@ -282,56 +353,56 @@ export function VendorResponseTable({
},
size: 300,
},
- {
- accessorKey: "description",
- header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />,
- cell: ({ row }) => (
- <div className="max-w-[200px] truncate" title={row.original.description || ""}>
- {row.original.description || "-"}
- </div>
- ),
- size: 200,
- },
- {
- accessorKey: "validTo",
- header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="유효기간" />,
- cell: ({ row }) => {
- const { validFrom, validTo } = row.original;
- const validity = checkValidity(validTo);
+ // {
+ // accessorKey: "description",
+ // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />,
+ // cell: ({ row }) => (
+ // <div className="max-w-[200px] truncate" title={row.original.description || ""}>
+ // {row.original.description || "-"}
+ // </div>
+ // ),
+ // size: 200,
+ // },
+ // {
+ // accessorKey: "validTo",
+ // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="유효기간" />,
+ // cell: ({ row }) => {
+ // const { validFrom, validTo } = row.original;
+ // const validity = checkValidity(validTo);
- if (!validTo) return <span className="text-muted-foreground">-</span>;
+ // if (!validTo) return <span className="text-muted-foreground">-</span>;
- return (
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <div className="flex items-center gap-2">
- {validity === "expired" && (
- <AlertCircle className="h-4 w-4 text-red-500" />
- )}
- {validity === "expiring-soon" && (
- <AlertCircle className="h-4 w-4 text-yellow-500" />
- )}
- <span className={cn(
- "text-sm",
- validity === "expired" && "text-red-500",
- validity === "expiring-soon" && "text-yellow-500"
- )}>
- {format(new Date(validTo), "yyyy-MM-dd")}
- </span>
- </div>
- </TooltipTrigger>
- <TooltipContent>
- <p>유효기간: {validFrom ? format(new Date(validFrom), "yyyy-MM-dd") : "?"} ~ {format(new Date(validTo), "yyyy-MM-dd")}</p>
- {validity === "expired" && <p className="text-red-500">만료됨</p>}
- {validity === "expiring-soon" && <p className="text-yellow-500">곧 만료 예정</p>}
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- );
- },
- size: 120,
- },
+ // return (
+ // <TooltipProvider>
+ // <Tooltip>
+ // <TooltipTrigger asChild>
+ // <div className="flex items-center gap-2">
+ // {validity === "expired" && (
+ // <AlertCircle className="h-4 w-4 text-red-500" />
+ // )}
+ // {validity === "expiring-soon" && (
+ // <AlertCircle className="h-4 w-4 text-yellow-500" />
+ // )}
+ // <span className={cn(
+ // "text-sm",
+ // validity === "expired" && "text-red-500",
+ // validity === "expiring-soon" && "text-yellow-500"
+ // )}>
+ // {format(new Date(validTo), "yyyy-MM-dd")}
+ // </span>
+ // </div>
+ // </TooltipTrigger>
+ // <TooltipContent>
+ // <p>유효기간: {validFrom ? format(new Date(validFrom), "yyyy-MM-dd") : "?"} ~ {format(new Date(validTo), "yyyy-MM-dd")}</p>
+ // {validity === "expired" && <p className="text-red-500">만료됨</p>}
+ // {validity === "expiring-soon" && <p className="text-yellow-500">곧 만료 예정</p>}
+ // </TooltipContent>
+ // </Tooltip>
+ // </TooltipProvider>
+ // );
+ // },
+ // size: 120,
+ // },
{
accessorKey: "responseStatus",
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="응답 상태" />,
@@ -424,13 +495,13 @@ export function VendorResponseTable({
label: "문서 유형",
type: "select",
options: [
- { label: "견적서", value: "견적서" },
- { label: "기술제안서", value: "기술제안서" },
- { label: "인증서", value: "인증서" },
- { label: "카탈로그", value: "카탈로그" },
- { label: "도면", value: "도면" },
- { label: "테스트성적서", value: "테스트성적서" },
- { label: "기타", value: "기타" },
+ { label: "구매", value: "구매" },
+ { label: "설계", value: "설계" },
+ // { label: "인증서", value: "인증서" },
+ // { label: "카탈로그", value: "카탈로그" },
+ // { label: "도면", value: "도면" },
+ // { label: "테스트성적서", value: "테스트성적서" },
+ // { label: "기타", value: "기타" },
]
},
{ id: "documentNo", label: "문서번호", type: "text" },
@@ -448,23 +519,35 @@ export function VendorResponseTable({
{ label: "취소", value: "취소" },
]
},
- { id: "validFrom", label: "유효시작일", type: "date" },
- { id: "validTo", label: "유효종료일", type: "date" },
+ // { id: "validFrom", label: "유효시작일", type: "date" },
+ // { id: "validTo", label: "유효종료일", type: "date" },
{ id: "uploadedAt", label: "업로드일", type: "date" },
];
- // 추가 액션 버튼들
+ // 추가 액션 버튼들 수정
const additionalActions = React.useMemo(() => (
<div className="flex items-center gap-2">
{selectedRows.length > 0 && (
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkDownload}
- >
- <Download className="h-4 w-4 mr-2" />
- 다운로드 ({selectedRows.length})
- </Button>
+ <>
+ {/* 문서 유형 변경 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowTypeDialog(true)}
+ >
+ <FileText className="h-4 w-4 mr-2" />
+ 유형 변경 ({selectedRows.length})
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkDownload}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드 ({selectedRows.length})
+ </Button>
+ </>
)}
<Button
variant="outline"
@@ -476,7 +559,7 @@ export function VendorResponseTable({
새로고침
</Button>
</div>
- ), [selectedRows, isRefreshing, handleBulkDownload, handleRefresh]);
+ ), [selectedRows, isRefreshing, handleBulkDownload, handleRefresh])
// 벤더별 그룹 카운트
const vendorCounts = React.useMemo(() => {
@@ -490,18 +573,71 @@ export function VendorResponseTable({
return (
<div className={cn("w-full space-y-4")}>
- {/* 벤더별 요약 정보 */}
- <div className="flex gap-2 flex-wrap">
- {Array.from(vendorCounts.entries()).map(([vendor, count]) => (
- <Badge key={vendor} variant="secondary">
- {vendor}: {count}
- </Badge>
- ))}
+ {/* 벤더 필터 섹션 */}
+ <div className="space-y-2">
+ {/* 필터 헤더 */}
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium text-muted-foreground">
+ 벤더별 필터
+ </span>
+ {selectedVendor && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setSelectedVendor(null)}
+ className="h-7 px-2 text-xs"
+ >
+ <X className="h-3 w-3 mr-1" />
+ 필터 초기화
+ </Button>
+ )}
+ </div>
+
+ {/* 벤더 버튼들 */}
+ <div className="flex gap-2 flex-wrap">
+ {/* 전체 보기 버튼 */}
+ <Button
+ variant={selectedVendor === null ? "default" : "outline"}
+ size="sm"
+ onClick={() => setSelectedVendor(null)}
+ className="h-7"
+ >
+ <span className="text-xs">
+ 전체 ({data.length})
+ </span>
+ </Button>
+
+ {/* 각 벤더별 버튼 */}
+ {Array.from(vendorCounts.entries()).map(([vendor, count]) => (
+ <Button
+ key={vendor}
+ variant={selectedVendor === vendor ? "default" : "outline"}
+ size="sm"
+ onClick={() => toggleVendorFilter(vendor)}
+ className="h-7"
+ >
+ <Building2 className="h-3 w-3 mr-1" />
+ <span className="text-xs">
+ {vendor} ({count})
+ </span>
+ </Button>
+ ))}
+ </div>
+
+ {/* 현재 필터 상태 표시 */}
+ {selectedVendor && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <AlertCircle className="h-3 w-3" />
+ <span>
+ "{selectedVendor}" 벤더의 {filteredData.length}개 항목만 표시 중
+ </span>
+ </div>
+ )}
</div>
<ClientDataTable
columns={columns}
- data={data}
+ data={filteredData} // 필터링된 데이터 사용
advancedFilterFields={advancedFilterFields}
autoSizeColumns={true}
compact={true}
@@ -514,6 +650,81 @@ export function VendorResponseTable({
>
{additionalActions}
</ClientDataTable>
+
+ {/* 문서 유형 변경 다이얼로그 */}
+ <Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>문서 유형 변경</DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedRows.length}개 항목의 문서 유형을 변경합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid gap-4 py-4">
+ <div className="grid grid-cols-4 items-center gap-4">
+ <label htmlFor="type" className="text-right">
+ 문서 유형
+ </label>
+ <Select
+ value={selectedType}
+ onValueChange={(value) => setSelectedType(value as "구매" | "설계")}
+ >
+ <SelectTrigger className="col-span-3">
+ <SelectValue placeholder="문서 유형 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="구매">구매</SelectItem>
+ <SelectItem value="설계">설계</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 현재 선택된 항목들의 정보 표시 */}
+ <div className="text-sm text-muted-foreground">
+ <p>변경될 항목:</p>
+ <ul className="mt-2 max-h-32 overflow-y-auto space-y-1">
+ {selectedRows.slice(0, 5).map((row) => (
+ <li key={row.id} className="text-xs">
+ • {row.vendorName} - {row.originalFileName}
+ </li>
+ ))}
+ {selectedRows.length > 5 && (
+ <li className="text-xs italic">
+ ... 외 {selectedRows.length - 5}개
+ </li>
+ )}
+ </ul>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setShowTypeDialog(false);
+ setSelectedType("");
+ }}
+ disabled={isUpdating}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleBulkTypeChange}
+ disabled={!selectedType || isUpdating}
+ >
+ {isUpdating ? (
+ <>
+ <RefreshCw className="mr-2 h-4 w-4 animate-spin" />
+ 변경 중...
+ </>
+ ) : (
+ "변경"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
</div>
);
} \ No newline at end of file
diff --git a/lib/rfq-last/compare-action.ts b/lib/rfq-last/compare-action.ts
new file mode 100644
index 00000000..5d210631
--- /dev/null
+++ b/lib/rfq-last/compare-action.ts
@@ -0,0 +1,500 @@
+"use server";
+
+import db from "@/db/db";
+import { eq, and, inArray } from "drizzle-orm";
+import {
+ rfqsLast,
+ rfqLastDetails,
+ rfqPrItems,
+ rfqLastVendorResponses,
+ rfqLastVendorQuotationItems,
+ vendors,
+ paymentTerms,
+ incoterms,
+} from "@/db/schema";
+
+export interface ComparisonData {
+ rfqInfo: {
+ id: number;
+ rfqCode: string;
+ rfqTitle: string;
+ rfqType: string;
+ projectCode?: string;
+ projectName?: string;
+ dueDate: Date | null;
+ packageNo?: string;
+ packageName?: string;
+ };
+ vendors: VendorComparison[];
+ prItems: PrItemComparison[];
+ summary: {
+ lowestBidder: string;
+ highestBidder: string;
+ priceRange: {
+ min: number;
+ max: number;
+ average: number;
+ };
+ currency: string;
+ };
+}
+
+export interface VendorComparison {
+ vendorId: number;
+ vendorName: string;
+ vendorCode: string;
+ vendorCountry?: string;
+
+ // 응답 정보
+ responseId: number;
+ participationStatus: string;
+ responseStatus: string;
+ submittedAt: Date | null;
+
+ // 가격 정보
+ totalAmount: number;
+ currency: string;
+ rank?: number;
+ priceVariance?: number; // 평균 대비 차이 %
+
+ // 구매자 제시 조건
+ buyerConditions: {
+ currency: string;
+ paymentTermsCode: string;
+ paymentTermsDesc?: string;
+ incotermsCode: string;
+ incotermsDesc?: string;
+ deliveryDate: Date | null;
+ contractDuration?: string;
+ taxCode?: string;
+ placeOfShipping?: string;
+ placeOfDestination?: string;
+
+ // 추가 조건
+ firstYn: boolean;
+ firstDescription?: string;
+ sparepartYn: boolean;
+ sparepartDescription?: string;
+ materialPriceRelatedYn: boolean;
+ };
+
+ // 벤더 제안 조건
+ vendorConditions: {
+ currency?: string;
+ paymentTermsCode?: string;
+ paymentTermsDesc?: string;
+ incotermsCode?: string;
+ incotermsDesc?: string;
+ deliveryDate?: Date | null;
+ contractDuration?: string;
+ taxCode?: string;
+ placeOfShipping?: string;
+ placeOfDestination?: string;
+
+ // 추가 조건 응답
+ firstAcceptance?: "수용" | "부분수용" | "거부";
+ firstDescription?: string;
+ sparepartAcceptance?: "수용" | "부분수용" | "거부";
+ sparepartDescription?: string;
+ materialPriceRelatedYn?: boolean;
+ materialPriceRelatedReason?: string;
+ };
+
+ // 조건 차이 분석
+ conditionDifferences: {
+ hasDifferences: boolean;
+ differences: string[];
+ criticalDifferences: string[]; // 중요한 차이점
+ };
+
+ // 비고
+ generalRemark?: string;
+ technicalProposal?: string;
+}
+
+export interface PrItemComparison {
+ prItemId: number;
+ prNo: string;
+ prItem: string;
+ materialCode: string;
+ materialDescription: string;
+ requestedQuantity: number;
+ uom: string;
+ requestedDeliveryDate: Date | null;
+
+ vendorQuotes: {
+ vendorId: number;
+ vendorName: string;
+ unitPrice: number;
+ totalPrice: number;
+ currency: string;
+ quotedQuantity: number;
+ deliveryDate?: Date | null;
+ leadTime?: number;
+ manufacturer?: string;
+ modelNo?: string;
+ technicalCompliance: boolean;
+ alternativeProposal?: string;
+ itemRemark?: string;
+ priceRank?: number;
+ }[];
+
+ priceAnalysis: {
+ lowestPrice: number;
+ highestPrice: number;
+ averagePrice: number;
+ priceVariance: number; // 표준편차
+ };
+}
+
+export async function getComparisonData(
+ rfqId: number,
+ vendorIds: number[]
+): Promise<ComparisonData | null> {
+ try {
+ // 1. RFQ 기본 정보 조회
+ const rfqData = await db
+ .select({
+ id: rfqsLast.id,
+ rfqCode: rfqsLast.rfqCode,
+ rfqTitle: rfqsLast.rfqTitle,
+ rfqType: rfqsLast.rfqType,
+ // projectCode: rfqsLast.projectCode,
+ // projectName: rfqsLast.projectName,
+ dueDate: rfqsLast.dueDate,
+ packageNo: rfqsLast.packageNo,
+ packageName: rfqsLast.packageName,
+ })
+ .from(rfqsLast)
+ .where(eq(rfqsLast.id, rfqId))
+ .limit(1);
+
+ if (!rfqData[0]) return null;
+
+ // 2. 벤더별 정보 및 응답 조회
+ const vendorData = await db
+ .select({
+ // 벤더 정보
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ vendorCountry: vendors.country,
+
+ // RFQ Details (구매자 조건)
+ detailId: rfqLastDetails.id,
+ buyerCurrency: rfqLastDetails.currency,
+ buyerPaymentTermsCode: rfqLastDetails.paymentTermsCode,
+ buyerIncotermsCode: rfqLastDetails.incotermsCode,
+ buyerIncotermsDetail: rfqLastDetails.incotermsDetail,
+ buyerDeliveryDate: rfqLastDetails.deliveryDate,
+ buyerContractDuration: rfqLastDetails.contractDuration,
+ buyerTaxCode: rfqLastDetails.taxCode,
+ buyerPlaceOfShipping: rfqLastDetails.placeOfShipping,
+ buyerPlaceOfDestination: rfqLastDetails.placeOfDestination,
+ buyerFirstYn: rfqLastDetails.firstYn,
+ buyerFirstDescription: rfqLastDetails.firstDescription,
+ buyerSparepartYn: rfqLastDetails.sparepartYn,
+ buyerSparepartDescription: rfqLastDetails.sparepartDescription,
+ buyerMaterialPriceRelatedYn: rfqLastDetails.materialPriceRelatedYn,
+
+ // 벤더 응답
+ responseId: rfqLastVendorResponses.id,
+ participationStatus: rfqLastVendorResponses.participationStatus,
+ responseStatus: rfqLastVendorResponses.status,
+ submittedAt: rfqLastVendorResponses.submittedAt,
+ totalAmount: rfqLastVendorResponses.totalAmount,
+ responseCurrency: rfqLastVendorResponses.currency,
+
+ // 벤더 제안 조건
+ vendorCurrency: rfqLastVendorResponses.vendorCurrency,
+ vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode,
+ vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode,
+ vendorIncotermsDetail: rfqLastVendorResponses.vendorIncotermsDetail,
+ vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate,
+ vendorContractDuration: rfqLastVendorResponses.vendorContractDuration,
+ vendorTaxCode: rfqLastVendorResponses.vendorTaxCode,
+ vendorPlaceOfShipping: rfqLastVendorResponses.vendorPlaceOfShipping,
+ vendorPlaceOfDestination: rfqLastVendorResponses.vendorPlaceOfDestination,
+
+ // 추가 조건 응답
+ vendorFirstAcceptance: rfqLastVendorResponses.vendorFirstAcceptance,
+ vendorFirstDescription: rfqLastVendorResponses.vendorFirstDescription,
+ vendorSparepartAcceptance: rfqLastVendorResponses.vendorSparepartAcceptance,
+ vendorSparepartDescription: rfqLastVendorResponses.vendorSparepartDescription,
+ vendorMaterialPriceRelatedYn: rfqLastVendorResponses.vendorMaterialPriceRelatedYn,
+ vendorMaterialPriceRelatedReason: rfqLastVendorResponses.vendorMaterialPriceRelatedReason,
+
+ // 비고
+ generalRemark: rfqLastVendorResponses.generalRemark,
+ technicalProposal: rfqLastVendorResponses.technicalProposal,
+ })
+ .from(vendors)
+ .innerJoin(
+ rfqLastDetails,
+ and(
+ eq(rfqLastDetails.vendorsId, vendors.id),
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ )
+ .leftJoin(
+ rfqLastVendorResponses,
+ and(
+ eq(rfqLastVendorResponses.vendorId, vendors.id),
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.isLatest, true)
+ )
+ )
+ .where(inArray(vendors.id, vendorIds));
+
+ // 3. Payment Terms와 Incoterms 설명 조회
+ const paymentTermsData = await db
+ .select({
+ code: paymentTerms.code,
+ description: paymentTerms.description,
+ })
+ .from(paymentTerms);
+
+ const incotermsData = await db
+ .select({
+ code: incoterms.code,
+ description: incoterms.description,
+ })
+ .from(incoterms);
+
+ const paymentTermsMap = new Map(
+ paymentTermsData.map(pt => [pt.code, pt.description])
+ );
+ const incotermsMap = new Map(
+ incotermsData.map(ic => [ic.code, ic.description])
+ );
+
+ // 4. PR Items 조회
+ const prItems = await db
+ .select({
+ id: rfqPrItems.id,
+ prNo: rfqPrItems.prNo,
+ prItem: rfqPrItems.prItem,
+ materialCode: rfqPrItems.materialCode,
+ materialDescription: rfqPrItems.materialDescription,
+ quantity: rfqPrItems.quantity,
+ uom: rfqPrItems.uom,
+ deliveryDate: rfqPrItems.deliveryDate,
+ })
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, rfqId));
+
+ // 5. 벤더별 견적 아이템 조회
+ const quotationItems = await db
+ .select({
+ vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId,
+ prItemId: rfqLastVendorQuotationItems.rfqPrItemId,
+ unitPrice: rfqLastVendorQuotationItems.unitPrice,
+ totalPrice: rfqLastVendorQuotationItems.totalPrice,
+ currency: rfqLastVendorQuotationItems.currency,
+ quantity: rfqLastVendorQuotationItems.quantity,
+ deliveryDate: rfqLastVendorQuotationItems.vendorDeliveryDate,
+ leadTime: rfqLastVendorQuotationItems.leadTime,
+ manufacturer: rfqLastVendorQuotationItems.manufacturer,
+ modelNo: rfqLastVendorQuotationItems.modelNo,
+ technicalCompliance: rfqLastVendorQuotationItems.technicalCompliance,
+ alternativeProposal: rfqLastVendorQuotationItems.alternativeProposal,
+ itemRemark: rfqLastVendorQuotationItems.itemRemark,
+ })
+ .from(rfqLastVendorQuotationItems)
+ .where(
+ inArray(
+ rfqLastVendorQuotationItems.vendorResponseId,
+ vendorData.map(v => v.responseId).filter(id => id != null)
+ )
+ );
+
+ // 6. 데이터 가공 및 분석
+ const validAmounts = vendorData
+ .map(v => v.totalAmount)
+ .filter(a => a != null && a > 0);
+
+ const minAmount = Math.min(...validAmounts);
+ const maxAmount = Math.max(...validAmounts);
+ const avgAmount = validAmounts.reduce((a, b) => a + b, 0) / validAmounts.length;
+
+ // 벤더별 비교 데이터 구성
+ const vendorComparisons: VendorComparison[] = vendorData.map((v, index) => {
+ const differences: string[] = [];
+ const criticalDifferences: string[] = [];
+
+ // 조건 차이 분석
+ if (v.vendorCurrency && v.vendorCurrency !== v.buyerCurrency) {
+ criticalDifferences.push(`통화: ${v.buyerCurrency} → ${v.vendorCurrency}`);
+ }
+
+ if (v.vendorPaymentTermsCode && v.vendorPaymentTermsCode !== v.buyerPaymentTermsCode) {
+ differences.push(`지급조건: ${v.buyerPaymentTermsCode} → ${v.vendorPaymentTermsCode}`);
+ }
+
+ if (v.vendorIncotermsCode && v.vendorIncotermsCode !== v.buyerIncotermsCode) {
+ differences.push(`인코텀즈: ${v.buyerIncotermsCode} → ${v.vendorIncotermsCode}`);
+ }
+
+ if (v.vendorDeliveryDate && v.buyerDeliveryDate) {
+ const buyerDate = new Date(v.buyerDeliveryDate);
+ const vendorDate = new Date(v.vendorDeliveryDate);
+ if (vendorDate > buyerDate) {
+ criticalDifferences.push(`납기: ${Math.ceil((vendorDate.getTime() - buyerDate.getTime()) / (1000 * 60 * 60 * 24))}일 지연`);
+ }
+ }
+
+ if (v.vendorFirstAcceptance === "거부" && v.buyerFirstYn) {
+ criticalDifferences.push("초도품 거부");
+ }
+
+ if (v.vendorSparepartAcceptance === "거부" && v.buyerSparepartYn) {
+ criticalDifferences.push("스페어파트 거부");
+ }
+
+ return {
+ vendorId: v.vendorId,
+ vendorName: v.vendorName,
+ vendorCode: v.vendorCode,
+ vendorCountry: v.vendorCountry,
+
+ responseId: v.responseId || 0,
+ participationStatus: v.participationStatus || "미응답",
+ responseStatus: v.responseStatus || "대기중",
+ submittedAt: v.submittedAt,
+
+ totalAmount: v.totalAmount || 0,
+ currency: v.responseCurrency || v.buyerCurrency || "USD",
+ rank: 0, // 나중에 계산
+ priceVariance: v.totalAmount ? ((v.totalAmount - avgAmount) / avgAmount) * 100 : 0,
+
+ buyerConditions: {
+ currency: v.buyerCurrency || "USD",
+ paymentTermsCode: v.buyerPaymentTermsCode || "",
+ paymentTermsDesc: paymentTermsMap.get(v.buyerPaymentTermsCode || ""),
+ incotermsCode: v.buyerIncotermsCode || "",
+ incotermsDesc: incotermsMap.get(v.buyerIncotermsCode || ""),
+ deliveryDate: v.buyerDeliveryDate,
+ contractDuration: v.buyerContractDuration,
+ taxCode: v.buyerTaxCode,
+ placeOfShipping: v.buyerPlaceOfShipping,
+ placeOfDestination: v.buyerPlaceOfDestination,
+ firstYn: v.buyerFirstYn || false,
+ firstDescription: v.buyerFirstDescription,
+ sparepartYn: v.buyerSparepartYn || false,
+ sparepartDescription: v.buyerSparepartDescription,
+ materialPriceRelatedYn: v.buyerMaterialPriceRelatedYn || false,
+ },
+
+ vendorConditions: {
+ currency: v.vendorCurrency,
+ paymentTermsCode: v.vendorPaymentTermsCode,
+ paymentTermsDesc: paymentTermsMap.get(v.vendorPaymentTermsCode || ""),
+ incotermsCode: v.vendorIncotermsCode,
+ incotermsDesc: incotermsMap.get(v.vendorIncotermsCode || ""),
+ deliveryDate: v.vendorDeliveryDate,
+ contractDuration: v.vendorContractDuration,
+ taxCode: v.vendorTaxCode,
+ placeOfShipping: v.vendorPlaceOfShipping,
+ placeOfDestination: v.vendorPlaceOfDestination,
+ firstAcceptance: v.vendorFirstAcceptance,
+ firstDescription: v.vendorFirstDescription,
+ sparepartAcceptance: v.vendorSparepartAcceptance,
+ sparepartDescription: v.vendorSparepartDescription,
+ materialPriceRelatedYn: v.vendorMaterialPriceRelatedYn,
+ materialPriceRelatedReason: v.vendorMaterialPriceRelatedReason,
+ },
+
+ conditionDifferences: {
+ hasDifferences: differences.length > 0 || criticalDifferences.length > 0,
+ differences,
+ criticalDifferences,
+ },
+
+ generalRemark: v.generalRemark,
+ technicalProposal: v.technicalProposal,
+ };
+ });
+
+ // 가격 순위 계산
+ vendorComparisons.sort((a, b) => a.totalAmount - b.totalAmount);
+ vendorComparisons.forEach((v, index) => {
+ v.rank = index + 1;
+ });
+
+ // PR 아이템별 비교 데이터 구성
+ const prItemComparisons: PrItemComparison[] = prItems.map(item => {
+ const itemQuotes = quotationItems
+ .filter(q => q.prItemId === item.id)
+ .map(q => {
+ const vendor = vendorData.find(v => v.responseId === q.vendorResponseId);
+ return {
+ vendorId: vendor?.vendorId || 0,
+ vendorName: vendor?.vendorName || "",
+ unitPrice: q.unitPrice || 0,
+ totalPrice: q.totalPrice || 0,
+ currency: q.currency || "USD",
+ quotedQuantity: q.quantity || 0,
+ deliveryDate: q.deliveryDate,
+ leadTime: q.leadTime,
+ manufacturer: q.manufacturer,
+ modelNo: q.modelNo,
+ technicalCompliance: q.technicalCompliance || true,
+ alternativeProposal: q.alternativeProposal,
+ itemRemark: q.itemRemark,
+ priceRank: 0,
+ };
+ });
+
+ // 아이템별 가격 순위
+ itemQuotes.sort((a, b) => a.unitPrice - b.unitPrice);
+ itemQuotes.forEach((q, index) => {
+ q.priceRank = index + 1;
+ });
+
+ const unitPrices = itemQuotes.map(q => q.unitPrice);
+ const avgPrice = unitPrices.reduce((a, b) => a + b, 0) / unitPrices.length || 0;
+ const variance = Math.sqrt(
+ unitPrices.reduce((sum, price) => sum + Math.pow(price - avgPrice, 2), 0) / unitPrices.length
+ );
+
+ return {
+ prItemId: item.id,
+ prNo: item.prNo || "",
+ prItem: item.prItem || "",
+ materialCode: item.materialCode || "",
+ materialDescription: item.materialDescription || "",
+ requestedQuantity: item.quantity || 0,
+ uom: item.uom || "",
+ requestedDeliveryDate: item.deliveryDate,
+ vendorQuotes: itemQuotes,
+ priceAnalysis: {
+ lowestPrice: Math.min(...unitPrices) || 0,
+ highestPrice: Math.max(...unitPrices) || 0,
+ averagePrice: avgPrice,
+ priceVariance: variance,
+ },
+ };
+ });
+
+ // 최종 데이터 구성
+ return {
+ rfqInfo: rfqData[0],
+ vendors: vendorComparisons,
+ prItems: prItemComparisons,
+ summary: {
+ lowestBidder: vendorComparisons[0]?.vendorName || "",
+ highestBidder: vendorComparisons[vendorComparisons.length - 1]?.vendorName || "",
+ priceRange: {
+ min: minAmount,
+ max: maxAmount,
+ average: avgAmount,
+ },
+ currency: vendorComparisons[0]?.currency || "USD",
+ },
+ };
+ } catch (error) {
+ console.error("견적 비교 데이터 조회 실패:", error);
+ return null;
+ }
+} \ No newline at end of file
diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx
new file mode 100644
index 00000000..0e15a7bf
--- /dev/null
+++ b/lib/rfq-last/quotation-compare-view.tsx
@@ -0,0 +1,755 @@
+"use client";
+
+import * as React from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Trophy,
+ TrendingUp,
+ TrendingDown,
+ AlertCircle,
+ CheckCircle,
+ XCircle,
+ ChevronDown,
+ ChevronUp,
+ Info,
+ DollarSign,
+ Calendar,
+ Package,
+ Globe,
+ FileText,
+ Truck,
+ AlertTriangle,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import type { ComparisonData, VendorComparison, PrItemComparison } from "../actions";
+
+interface QuotationCompareViewProps {
+ data: ComparisonData;
+}
+
+export function QuotationCompareView({ data }: QuotationCompareViewProps) {
+ const [expandedItems, setExpandedItems] = React.useState<Set<number>>(new Set());
+ const [selectedMetric, setSelectedMetric] = React.useState<"price" | "delivery" | "compliance">("price");
+
+ // 아이템 확장/축소 토글
+ const toggleItemExpansion = (itemId: number) => {
+ setExpandedItems((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(itemId)) {
+ newSet.delete(itemId);
+ } else {
+ newSet.add(itemId);
+ }
+ return newSet;
+ });
+ };
+
+ // 순위에 따른 색상
+ const getRankColor = (rank: number) => {
+ switch (rank) {
+ case 1:
+ return "text-green-600 bg-green-50";
+ case 2:
+ return "text-blue-600 bg-blue-50";
+ case 3:
+ return "text-orange-600 bg-orange-50";
+ default:
+ return "text-gray-600 bg-gray-50";
+ }
+ };
+
+ // 가격 차이 색상
+ const getVarianceColor = (variance: number) => {
+ if (variance < -5) return "text-green-600";
+ if (variance > 5) return "text-red-600";
+ return "text-gray-600";
+ };
+
+ // 조건 일치 여부 아이콘
+ const getComplianceIcon = (matches: boolean) => {
+ return matches ? (
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ ) : (
+ <XCircle className="h-4 w-4 text-red-500" />
+ );
+ };
+
+ // 금액 포맷
+ const formatAmount = (amount: number, currency: string = "USD") => {
+ return new Intl.NumberFormat("ko-KR", {
+ style: "currency",
+ currency: currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ }).format(amount);
+ };
+
+ return (
+ <div className="space-y-6">
+ {/* 요약 카드 */}
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+ {/* 최저가 벤더 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
+ <Trophy className="h-4 w-4 text-yellow-500" />
+ 최저가 벤더
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-lg font-bold">{data.summary.lowestBidder}</p>
+ <p className="text-sm text-muted-foreground">
+ {formatAmount(data.summary.priceRange.min, data.summary.currency)}
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 평균 가격 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
+ <DollarSign className="h-4 w-4" />
+ 평균 가격
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-lg font-bold">
+ {formatAmount(data.summary.priceRange.average, data.summary.currency)}
+ </p>
+ <p className="text-sm text-muted-foreground">
+ {data.vendors.length}개 업체 평균
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 가격 범위 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
+ <TrendingUp className="h-4 w-4" />
+ 가격 범위
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-lg font-bold">
+ {((data.summary.priceRange.max - data.summary.priceRange.min) / data.summary.priceRange.min * 100).toFixed(1)}%
+ </p>
+ <p className="text-sm text-muted-foreground">
+ 최저가 대비 최고가 차이
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 조건 불일치 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
+ <AlertCircle className="h-4 w-4 text-orange-500" />
+ 조건 불일치
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-lg font-bold">
+ {data.vendors.filter(v => v.conditionDifferences.hasDifferences).length}개
+ </p>
+ <p className="text-sm text-muted-foreground">
+ 제시 조건과 차이 있음
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 탭 뷰 */}
+ <Tabs defaultValue="overview" className="w-full">
+ <TabsList className="grid w-full grid-cols-4">
+ <TabsTrigger value="overview">종합 비교</TabsTrigger>
+ <TabsTrigger value="conditions">조건 비교</TabsTrigger>
+ <TabsTrigger value="items">아이템별 비교</TabsTrigger>
+ <TabsTrigger value="analysis">상세 분석</TabsTrigger>
+ </TabsList>
+
+ {/* 종합 비교 */}
+ <TabsContent value="overview" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>가격 순위</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {data.vendors.map((vendor) => (
+ <div
+ key={vendor.vendorId}
+ className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors"
+ >
+ <div className="flex items-center gap-4">
+ <div
+ className={cn(
+ "w-10 h-10 rounded-full flex items-center justify-center font-bold",
+ getRankColor(vendor.rank || 0)
+ )}
+ >
+ {vendor.rank}
+ </div>
+ <div>
+ <p className="font-semibold">{vendor.vendorName}</p>
+ <p className="text-sm text-muted-foreground">
+ {vendor.vendorCode} • {vendor.vendorCountry}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-6">
+ {/* 조건 차이 표시 */}
+ {vendor.conditionDifferences.criticalDifferences.length > 0 && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Badge variant="destructive" className="gap-1">
+ <AlertTriangle className="h-3 w-3" />
+ 중요 차이 {vendor.conditionDifferences.criticalDifferences.length}
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="space-y-1">
+ {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => (
+ <p key={idx} className="text-xs">{diff}</p>
+ ))}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+
+ {/* 가격 정보 */}
+ <div className="text-right">
+ <p className="text-lg font-bold">
+ {formatAmount(vendor.totalAmount, vendor.currency)}
+ </p>
+ <p className={cn("text-sm", getVarianceColor(vendor.priceVariance || 0))}>
+ {vendor.priceVariance && vendor.priceVariance > 0 ? "+" : ""}
+ {vendor.priceVariance?.toFixed(1)}% vs 평균
+ </p>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 조건 비교 */}
+ <TabsContent value="conditions" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>거래 조건 비교</CardTitle>
+ </CardHeader>
+ <CardContent className="overflow-x-auto">
+ <table className="w-full">
+ <thead>
+ <tr className="border-b">
+ <th className="text-left p-2">항목</th>
+ <th className="text-left p-2">구매자 제시</th>
+ {data.vendors.map((vendor) => (
+ <th key={vendor.vendorId} className="text-left p-2">
+ {vendor.vendorName}
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody className="divide-y">
+ {/* 통화 */}
+ <tr>
+ <td className="p-2 font-medium">통화</td>
+ <td className="p-2">{data.vendors[0]?.buyerConditions.currency}</td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ <div className="flex items-center gap-2">
+ {vendor.vendorConditions.currency || vendor.buyerConditions.currency}
+ {vendor.vendorConditions.currency !== vendor.buyerConditions.currency && (
+ <Badge variant="outline" className="text-xs">변경</Badge>
+ )}
+ </div>
+ </td>
+ ))}
+ </tr>
+
+ {/* 지급조건 */}
+ <tr>
+ <td className="p-2 font-medium">지급조건</td>
+ <td className="p-2">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ {data.vendors[0]?.buyerConditions.paymentTermsCode}
+ </TooltipTrigger>
+ <TooltipContent>
+ {data.vendors[0]?.buyerConditions.paymentTermsDesc}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ <div className="flex items-center gap-2">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ {vendor.vendorConditions.paymentTermsCode || vendor.buyerConditions.paymentTermsCode}
+ </TooltipTrigger>
+ <TooltipContent>
+ {vendor.vendorConditions.paymentTermsDesc || vendor.buyerConditions.paymentTermsDesc}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ {vendor.vendorConditions.paymentTermsCode &&
+ vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && (
+ <Badge variant="outline" className="text-xs">변경</Badge>
+ )}
+ </div>
+ </td>
+ ))}
+ </tr>
+
+ {/* 인코텀즈 */}
+ <tr>
+ <td className="p-2 font-medium">인코텀즈</td>
+ <td className="p-2">{data.vendors[0]?.buyerConditions.incotermsCode}</td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ <div className="flex items-center gap-2">
+ {vendor.vendorConditions.incotermsCode || vendor.buyerConditions.incotermsCode}
+ {vendor.vendorConditions.incotermsCode !== vendor.buyerConditions.incotermsCode && (
+ <Badge variant="outline" className="text-xs">변경</Badge>
+ )}
+ </div>
+ </td>
+ ))}
+ </tr>
+
+ {/* 납기 */}
+ <tr>
+ <td className="p-2 font-medium">납기</td>
+ <td className="p-2">
+ {data.vendors[0]?.buyerConditions.deliveryDate
+ ? format(new Date(data.vendors[0].buyerConditions.deliveryDate), "yyyy-MM-dd")
+ : "-"}
+ </td>
+ {data.vendors.map((vendor) => {
+ const vendorDate = vendor.vendorConditions.deliveryDate || vendor.buyerConditions.deliveryDate;
+ const isDelayed = vendorDate && vendor.buyerConditions.deliveryDate &&
+ new Date(vendorDate) > new Date(vendor.buyerConditions.deliveryDate);
+
+ return (
+ <td key={vendor.vendorId} className="p-2">
+ <div className="flex items-center gap-2">
+ {vendorDate ? format(new Date(vendorDate), "yyyy-MM-dd") : "-"}
+ {isDelayed && (
+ <Badge variant="destructive" className="text-xs">지연</Badge>
+ )}
+ </div>
+ </td>
+ );
+ })}
+ </tr>
+
+ {/* 초도품 */}
+ <tr>
+ <td className="p-2 font-medium">초도품</td>
+ <td className="p-2">
+ {data.vendors[0]?.buyerConditions.firstYn ? "요구" : "해당없음"}
+ </td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ {vendor.buyerConditions.firstYn && (
+ <Badge
+ variant={
+ vendor.vendorConditions.firstAcceptance === "수용"
+ ? "default"
+ : vendor.vendorConditions.firstAcceptance === "부분수용"
+ ? "secondary"
+ : vendor.vendorConditions.firstAcceptance === "거부"
+ ? "destructive"
+ : "outline"
+ }
+ >
+ {vendor.vendorConditions.firstAcceptance || "미응답"}
+ </Badge>
+ )}
+ {!vendor.buyerConditions.firstYn && "-"}
+ </td>
+ ))}
+ </tr>
+
+ {/* 스페어파트 */}
+ <tr>
+ <td className="p-2 font-medium">스페어파트</td>
+ <td className="p-2">
+ {data.vendors[0]?.buyerConditions.sparepartYn ? "요구" : "해당없음"}
+ </td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ {vendor.buyerConditions.sparepartYn && (
+ <Badge
+ variant={
+ vendor.vendorConditions.sparepartAcceptance === "수용"
+ ? "default"
+ : vendor.vendorConditions.sparepartAcceptance === "부분수용"
+ ? "secondary"
+ : vendor.vendorConditions.sparepartAcceptance === "거부"
+ ? "destructive"
+ : "outline"
+ }
+ >
+ {vendor.vendorConditions.sparepartAcceptance || "미응답"}
+ </Badge>
+ )}
+ {!vendor.buyerConditions.sparepartYn && "-"}
+ </td>
+ ))}
+ </tr>
+
+ {/* 연동제 */}
+ <tr>
+ <td className="p-2 font-medium">연동제</td>
+ <td className="p-2">
+ {data.vendors[0]?.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"}
+ </td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ <div className="flex items-center gap-2">
+ {vendor.vendorConditions.materialPriceRelatedYn !== undefined
+ ? vendor.vendorConditions.materialPriceRelatedYn ? "적용" : "미적용"
+ : vendor.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"}
+ {vendor.vendorConditions.materialPriceRelatedReason && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Info className="h-3 w-3" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs text-xs">
+ {vendor.vendorConditions.materialPriceRelatedReason}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
+ </td>
+ ))}
+ </tr>
+ </tbody>
+ </table>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 아이템별 비교 */}
+ <TabsContent value="items" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>PR 아이템별 가격 비교</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-2">
+ {data.prItems.map((item) => (
+ <Collapsible
+ key={item.prItemId}
+ open={expandedItems.has(item.prItemId)}
+ onOpenChange={() => toggleItemExpansion(item.prItemId)}
+ >
+ <div className="border rounded-lg">
+ <CollapsibleTrigger className="w-full p-4 hover:bg-gray-50 transition-colors">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4 text-left">
+ <div className="flex items-center gap-2">
+ {expandedItems.has(item.prItemId) ? (
+ <ChevronUp className="h-4 w-4" />
+ ) : (
+ <ChevronDown className="h-4 w-4" />
+ )}
+ <Package className="h-4 w-4 text-muted-foreground" />
+ </div>
+ <div>
+ <p className="font-medium">{item.materialDescription}</p>
+ <p className="text-sm text-muted-foreground">
+ {item.materialCode} • {item.prNo} • {item.requestedQuantity} {item.uom}
+ </p>
+ </div>
+ </div>
+ <div className="text-right">
+ <p className="text-sm text-muted-foreground">단가 범위</p>
+ <p className="font-semibold">
+ {formatAmount(item.priceAnalysis.lowestPrice)} ~ {formatAmount(item.priceAnalysis.highestPrice)}
+ </p>
+ </div>
+ </div>
+ </CollapsibleTrigger>
+
+ <CollapsibleContent>
+ <div className="p-4 pt-0">
+ <table className="w-full">
+ <thead>
+ <tr className="border-b text-sm">
+ <th className="text-left p-2">벤더</th>
+ <th className="text-right p-2">단가</th>
+ <th className="text-right p-2">총액</th>
+ <th className="text-right p-2">수량</th>
+ <th className="text-left p-2">납기</th>
+ <th className="text-left p-2">제조사</th>
+ <th className="text-center p-2">순위</th>
+ </tr>
+ </thead>
+ <tbody className="divide-y">
+ {item.vendorQuotes.map((quote) => (
+ <tr key={quote.vendorId} className="text-sm">
+ <td className="p-2 font-medium">{quote.vendorName}</td>
+ <td className="p-2 text-right">
+ {formatAmount(quote.unitPrice, quote.currency)}
+ </td>
+ <td className="p-2 text-right">
+ {formatAmount(quote.totalPrice, quote.currency)}
+ </td>
+ <td className="p-2 text-right">{quote.quotedQuantity}</td>
+ <td className="p-2">
+ {quote.deliveryDate
+ ? format(new Date(quote.deliveryDate), "yyyy-MM-dd")
+ : quote.leadTime
+ ? `${quote.leadTime}일`
+ : "-"}
+ </td>
+ <td className="p-2">
+ {quote.manufacturer && (
+ <div>
+ <p>{quote.manufacturer}</p>
+ {quote.modelNo && (
+ <p className="text-xs text-muted-foreground">{quote.modelNo}</p>
+ )}
+ </div>
+ )}
+ </td>
+ <td className="p-2 text-center">
+ <Badge className={cn("", getRankColor(quote.priceRank || 0))}>
+ #{quote.priceRank}
+ </Badge>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+
+ {/* 가격 분석 요약 */}
+ <div className="mt-4 p-3 bg-gray-50 rounded-lg">
+ <div className="grid grid-cols-4 gap-4 text-sm">
+ <div>
+ <p className="text-muted-foreground">평균 단가</p>
+ <p className="font-semibold">
+ {formatAmount(item.priceAnalysis.averagePrice)}
+ </p>
+ </div>
+ <div>
+ <p className="text-muted-foreground">가격 편차</p>
+ <p className="font-semibold">
+ ±{formatAmount(item.priceAnalysis.priceVariance)}
+ </p>
+ </div>
+ <div>
+ <p className="text-muted-foreground">최저가 업체</p>
+ <p className="font-semibold">
+ {item.vendorQuotes.find(q => q.priceRank === 1)?.vendorName}
+ </p>
+ </div>
+ <div>
+ <p className="text-muted-foreground">가격 차이</p>
+ <p className="font-semibold">
+ {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) /
+ item.priceAnalysis.lowestPrice * 100).toFixed(1)}%
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </CollapsibleContent>
+ </div>
+ </Collapsible>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 상세 분석 */}
+ <TabsContent value="analysis" className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 위험 요소 분석 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <AlertTriangle className="h-5 w-5 text-orange-500" />
+ 위험 요소 분석
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-3">
+ {data.vendors.map((vendor) => {
+ if (!vendor.conditionDifferences.hasDifferences) return null;
+
+ return (
+ <div key={vendor.vendorId} className="p-3 border rounded-lg">
+ <p className="font-medium mb-2">{vendor.vendorName}</p>
+ {vendor.conditionDifferences.criticalDifferences.length > 0 && (
+ <div className="space-y-1 mb-2">
+ <p className="text-xs font-medium text-red-600">중요 차이점:</p>
+ {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => (
+ <p key={idx} className="text-xs text-red-600 pl-2">• {diff}</p>
+ ))}
+ </div>
+ )}
+ {vendor.conditionDifferences.differences.length > 0 && (
+ <div className="space-y-1">
+ <p className="text-xs font-medium text-orange-600">일반 차이점:</p>
+ {vendor.conditionDifferences.differences.map((diff, idx) => (
+ <p key={idx} className="text-xs text-orange-600 pl-2">• {diff}</p>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 추천 사항 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Info className="h-5 w-5 text-blue-500" />
+ 선정 추천
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {/* 가격 기준 추천 */}
+ <div className="p-3 bg-green-50 border border-green-200 rounded-lg">
+ <p className="font-medium text-green-800 mb-1">가격 우선 선정</p>
+ <p className="text-sm text-green-700">
+ {data.vendors[0]?.vendorName} - {formatAmount(data.vendors[0]?.totalAmount || 0)}
+ </p>
+ {data.vendors[0]?.conditionDifferences.hasDifferences && (
+ <p className="text-xs text-orange-600 mt-1">
+ ⚠️ 조건 차이 검토 필요
+ </p>
+ )}
+ </div>
+
+ {/* 조건 준수 기준 추천 */}
+ <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
+ <p className="font-medium text-blue-800 mb-1">조건 준수 우선 선정</p>
+ {(() => {
+ const compliantVendor = data.vendors.find(v => !v.conditionDifferences.hasDifferences);
+ if (compliantVendor) {
+ return (
+ <div>
+ <p className="text-sm text-blue-700">
+ {compliantVendor.vendorName} - {formatAmount(compliantVendor.totalAmount)}
+ </p>
+ <p className="text-xs text-blue-600 mt-1">
+ 모든 조건 충족 (가격 순위: #{compliantVendor.rank})
+ </p>
+ </div>
+ );
+ }
+ return (
+ <p className="text-sm text-blue-700">
+ 모든 조건을 충족하는 벤더 없음
+ </p>
+ );
+ })()}
+ </div>
+
+ {/* 균형 추천 */}
+ <div className="p-3 bg-purple-50 border border-purple-200 rounded-lg">
+ <p className="font-medium text-purple-800 mb-1">균형 선정 (추천)</p>
+ {(() => {
+ // 가격 순위와 조건 차이를 고려한 점수 계산
+ const scoredVendors = data.vendors.map(v => ({
+ ...v,
+ score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 +
+ v.conditionDifferences.differences.length
+ }));
+ scoredVendors.sort((a, b) => a.score - b.score);
+ const recommended = scoredVendors[0];
+
+ return (
+ <div>
+ <p className="text-sm text-purple-700">
+ {recommended.vendorName} - {formatAmount(recommended.totalAmount)}
+ </p>
+ <p className="text-xs text-purple-600 mt-1">
+ 가격 순위 #{recommended.rank}, 조건 차이 최소화
+ </p>
+ </div>
+ );
+ })()}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 벤더별 비고사항 */}
+ {data.vendors.some(v => v.generalRemark || v.technicalProposal) && (
+ <Card>
+ <CardHeader>
+ <CardTitle>벤더 제안사항 및 비고</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {data.vendors.map((vendor) => {
+ if (!vendor.generalRemark && !vendor.technicalProposal) return null;
+
+ return (
+ <div key={vendor.vendorId} className="border rounded-lg p-4">
+ <p className="font-medium mb-2">{vendor.vendorName}</p>
+ {vendor.generalRemark && (
+ <div className="mb-2">
+ <p className="text-sm font-medium text-muted-foreground">일반 비고:</p>
+ <p className="text-sm">{vendor.generalRemark}</p>
+ </div>
+ )}
+ {vendor.technicalProposal && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">기술 제안:</p>
+ <p className="text-sm">{vendor.technicalProposal}</p>
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 9943c02d..02429b6a 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -2847,7 +2847,7 @@ export async function sendRfqToVendors({
const picInfo = await getPicInfo(rfqData.picId, rfqData.picName);
// 3. 프로젝트 정보 조회
- const projectInfo = rfqData.projectId
+ const projectInfo = rfqData.projectId
? await getProjectInfo(rfqData.projectId)
: null;
@@ -2856,7 +2856,7 @@ export async function sendRfqToVendors({
const designAttachments = await getDesignAttachments(rfqId);
// 5. 벤더별 처리
- const { results, errors, savedContracts, tbeSessionsCreated } =
+ const { results, errors, savedContracts, tbeSessionsCreated } =
await processVendors({
rfqId,
rfqData,
@@ -2979,17 +2979,26 @@ async function prepareEmailAttachments(rfqId: number, attachmentIds: number[]) {
);
const emailAttachments = [];
-
+
for (const { attachment, revision } of attachments) {
if (revision?.filePath) {
try {
- const fullPath = path.join(
- process.cwd(),
- `${process.env.NAS_PATH}`,
+
+ const isProduction = process.env.NODE_ENV === "production";
+
+ const fullPath = isProduction
+
+ path.join(
+ process.cwd(),
+ `public`,
+ revision.filePath
+ )
+ : path.join(
+ `${process.env.NAS_PATH}`,
revision.filePath
);
const fileBuffer = await fs.readFile(fullPath);
-
+
emailAttachments.push({
filename: revision.originalFileName,
content: fileBuffer,
@@ -3052,9 +3061,9 @@ async function processVendors({
// PDF 저장 디렉토리 준비
const contractsDir = path.join(
- process.cwd(),
- `${process.env.NAS_PATH}`,
- "contracts",
+ process.cwd(),
+ `${process.env.NAS_PATH}`,
+ "contracts",
"generated"
);
await mkdir(contractsDir, { recursive: true });
@@ -3077,18 +3086,18 @@ async function processVendors({
});
results.push(vendorResult.result);
-
+
if (vendorResult.contracts) {
savedContracts.push(...vendorResult.contracts);
}
-
+
if (vendorResult.tbeSession) {
tbeSessionsCreated.push(vendorResult.tbeSession);
}
} catch (error) {
console.error(`벤더 ${vendor.vendorName} 처리 실패:`, error);
-
+
errors.push({
vendorId: vendor.vendorId,
vendorName: vendor.vendorName,
@@ -3182,7 +3191,7 @@ function prepareEmailRecipients(vendor: any, picEmail: string) {
vendor.customEmails?.forEach((custom: any) => {
if (custom.email !== vendor.selectedMainEmail &&
- !vendor.additionalEmails.includes(custom.email)) {
+ !vendor.additionalEmails.includes(custom.email)) {
ccEmails.push(custom.email);
}
});
@@ -3235,14 +3244,14 @@ async function handleRfqDetail({
);
// 새 detail 생성
- const {
- id,
- updatedBy,
- updatedAt,
- isLatest,
- sendVersion: oldSendVersion,
- emailResentCount,
- ...restRfqDetail
+ const {
+ id,
+ updatedBy,
+ updatedAt,
+ isLatest,
+ sendVersion: oldSendVersion,
+ emailResentCount,
+ ...restRfqDetail
} = rfqDetail;
const [newRfqDetail] = await tx
@@ -3265,7 +3274,7 @@ async function handleRfqDetail({
})
.returning();
- await tx
+ await tx
.update(basicContract)
.set({
rfqCompanyId: newRfqDetail.id,
@@ -3273,7 +3282,7 @@ async function handleRfqDetail({
.where(
and(
eq(basicContract.rfqCompanyId, rfqDetail.id),
- eq(rfqLastDetails.vendorsId, vendor.vendorId),
+ eq(basicContract.vendorId, vendor.vendorId),
)
);
@@ -3382,7 +3391,7 @@ async function createOrUpdateContract({
})
.where(eq(basicContract.id, existingContract.id))
.returning();
-
+
return { ...updated, isUpdated: true };
} else {
// 새로 생성
@@ -3401,7 +3410,7 @@ async function createOrUpdateContract({
updatedAt: new Date()
})
.returning();
-
+
return { ...created, isUpdated: false };
}
}
@@ -3503,11 +3512,11 @@ async function handleTbeSession({
sessionType: "initial",
status: "준비중",
evaluationResult: null,
- plannedStartDate: rfqData.dueDate
- ? addDays(new Date(rfqData.dueDate), 1)
+ plannedStartDate: rfqData.dueDate
+ ? addDays(new Date(rfqData.dueDate), 1)
: addDays(new Date(), 14),
- plannedEndDate: rfqData.dueDate
- ? addDays(new Date(rfqData.dueDate), 7)
+ plannedEndDate: rfqData.dueDate
+ ? addDays(new Date(rfqData.dueDate), 7)
: addDays(new Date(), 21),
leadEvaluatorId: rfqData.picId,
createdBy: Number(currentUser.id),
@@ -3536,11 +3545,11 @@ async function handleTbeSession({
async function generateTbeSessionCode(tx: any) {
const year = new Date().getFullYear();
const pattern = `TBE-${year}-%`;
-
+
const [lastTbeSession] = await tx
.select({ sessionCode: rfqLastTbeSessions.sessionCode })
.from(rfqLastTbeSessions)
- .where(like(rfqLastTbeSessions.sessionCode,pattern ))
+ .where(like(rfqLastTbeSessions.sessionCode, pattern))
.orderBy(sql`${rfqLastTbeSessions.sessionCode} DESC`)
.limit(1);
@@ -3624,7 +3633,7 @@ async function updateRfqStatus(rfqId: number, userId: number) {
updatedAt: new Date()
})
.where(eq(rfqsLast.id, rfqId));
- }
+}
export async function updateRfqDueDate(
rfqId: number,
@@ -4006,4 +4015,305 @@ function getTemplateNameByType(
case "기술자료": return "기술";
default: return contractType;
}
+}
+
+
+export async function updateAttachmentTypes(
+ attachmentIds: number[],
+ attachmentType: "구매" | "설계"
+) {
+ try {
+ // 권한 체크 등 필요시 추가
+
+ await db
+ .update(rfqLastVendorAttachments)
+ .set({ attachmentType })
+ .where(inArray(rfqLastVendorAttachments.id, attachmentIds));
+
+ // 페이지 리밸리데이션
+ // revalidatePath("/rfq");
+
+ return { success: true, message: `${attachmentIds.length}개 항목이 "${attachmentType}"로 변경되었습니다.` };
+ } catch (error) {
+ console.error("Failed to update attachment types:", error);
+ return { success: false, message: "문서 유형 변경에 실패했습니다." };
+ }
+}
+
+// 단일 RFQ 밀봉 토글
+export async function toggleRfqSealed(rfqId: number) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+ // 현재 상태 조회
+ const [currentRfq] = await db
+ .select({ rfqSealedYn: rfqsLast.rfqSealedYn })
+ .from(rfqsLast)
+ .where(eq(rfqsLast.id, rfqId));
+
+ if (!currentRfq) {
+ throw new Error("RFQ를 찾을 수 없습니다.");
+ }
+
+ // 상태 토글
+ const [updated] = await db
+ .update(rfqsLast)
+ .set({
+ rfqSealedYn: !currentRfq.rfqSealedYn,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, rfqId))
+ .returning();
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ data: updated,
+ message: updated.rfqSealedYn ? "견적이 밀봉되었습니다." : "견적 밀봉이 해제되었습니다.",
+ };
+ } catch (error) {
+ console.error("RFQ 밀봉 상태 변경 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 여러 RFQ 일괄 밀봉
+export async function sealMultipleRfqs(rfqIds: number[]) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ if (!rfqIds || rfqIds.length === 0) {
+ throw new Error("선택된 RFQ가 없습니다.");
+ }
+
+ const updated = await db
+ .update(rfqsLast)
+ .set({
+ rfqSealedYn: true,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(inArray(rfqsLast.id, rfqIds))
+ .returning();
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ count: updated.length,
+ message: `${updated.length}건의 견적이 밀봉되었습니다.`,
+ };
+ } catch (error) {
+ console.error("RFQ 일괄 밀봉 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 여러 RFQ 일괄 밀봉 해제
+export async function unsealMultipleRfqs(rfqIds: number[]) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+ if (!rfqIds || rfqIds.length === 0) {
+ throw new Error("선택된 RFQ가 없습니다.");
+ }
+
+ const updated = await db
+ .update(rfqsLast)
+ .set({
+ rfqSealedYn: false,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(inArray(rfqsLast.id, rfqIds))
+ .returning();
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ count: updated.length,
+ message: `${updated.length}건의 견적 밀봉이 해제되었습니다.`,
+ };
+ } catch (error) {
+ console.error("RFQ 밀봉 해제 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 단일 RFQ 밀봉 (밀봉만)
+export async function sealRfq(rfqId: number) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const [updated] = await db
+ .update(rfqsLast)
+ .set({
+ rfqSealedYn: true,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, rfqId))
+ .returning();
+
+ if (!updated) {
+ throw new Error("RFQ를 찾을 수 없습니다.");
+ }
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ data: updated,
+ message: "견적이 밀봉되었습니다.",
+ };
+ } catch (error) {
+ console.error("RFQ 밀봉 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 단일 RFQ 밀봉 해제
+export async function unsealRfq(rfqId: number) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const [updated] = await db
+ .update(rfqsLast)
+ .set({
+ rfqSealedYn: false,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, rfqId))
+ .returning();
+
+ if (!updated) {
+ throw new Error("RFQ를 찾을 수 없습니다.");
+ }
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ data: updated,
+ message: "견적 밀봉이 해제되었습니다.",
+ };
+ } catch (error) {
+ console.error("RFQ 밀봉 해제 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+
+
+export async function updateShortList(
+ rfqId: number,
+ vendorIds: number[],
+ shortListStatus: boolean = true
+) {
+ try {
+ // 권한 체크 등 필요한 검증
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // 해당 RFQ의 모든 벤더들의 shortList를 먼저 false로 설정 (선택적)
+ // 만약 선택된 것만 true로 하고 나머지는 그대로 두려면 이 부분 제거
+ await tx
+ .update(rfqLastDetails)
+ .set({
+ shortList: false,
+ updatedBy: session.user.id,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ );
+
+ // 선택된 벤더들의 shortList를 true로 설정
+ if (vendorIds.length > 0) {
+ const updates = await Promise.all(
+ vendorIds.map(vendorId =>
+ tx
+ .update(rfqLastDetails)
+ .set({
+ shortList: shortListStatus,
+ updatedBy: session.user.id,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.vendorsId, vendorId),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ )
+ .returning()
+ )
+ );
+
+ return {
+ success: true,
+ updatedCount: updates.length,
+ vendorIds
+ };
+ }
+
+ return {
+ success: true,
+ updatedCount: 0,
+ vendorIds: []
+ };
+ });
+
+ // revalidatePath(`/buyer/rfq/${rfqId}`);
+ return result;
+
+ } catch (error) {
+ console.error("Short List 업데이트 실패:", error);
+ throw new Error("Short List 업데이트에 실패했습니다.");
+ }
} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-seal-toggle-cell.tsx b/lib/rfq-last/table/rfq-seal-toggle-cell.tsx
new file mode 100644
index 00000000..99360978
--- /dev/null
+++ b/lib/rfq-last/table/rfq-seal-toggle-cell.tsx
@@ -0,0 +1,93 @@
+
+"use client";
+
+import * as React from "react";
+import { Lock, LockOpen } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { toast } from "sonner";
+import { toggleRfqSealed } from "../service";
+
+interface RfqSealToggleCellProps {
+ rfqId: number;
+ isSealed: boolean;
+ onUpdate?: () => void;
+}
+
+export function RfqSealToggleCell({
+ rfqId,
+ isSealed,
+ onUpdate
+}: RfqSealToggleCellProps) {
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [currentSealed, setCurrentSealed] = React.useState(isSealed);
+
+ const handleToggle = async (e: React.MouseEvent) => {
+ e.stopPropagation(); // 행 선택 방지
+
+ setIsLoading(true);
+ try {
+ const result = await toggleRfqSealed(rfqId);
+
+ if (result.success) {
+ setCurrentSealed(result.data?.rfqSealedYn ?? !currentSealed);
+ toast.success(result.message);
+ onUpdate?.(); // 테이블 데이터 새로고침
+ } else {
+ toast.error(result.error);
+ }
+ } catch (error) {
+ toast.error("밀봉 상태 변경 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ onClick={handleToggle}
+ disabled={isLoading}
+ >
+ {currentSealed ? (
+ <Lock className="h-4 w-4 text-red-500" />
+ ) : (
+ <LockOpen className="h-4 w-4 text-gray-400" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{currentSealed ? "밀봉 해제하기" : "밀봉하기"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+}
+
+export const sealColumn = {
+ accessorKey: "rfqSealedYn",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />,
+ cell: ({ row, table }) => (
+ <RfqSealToggleCell
+ rfqId={row.original.id}
+ isSealed={row.original.rfqSealedYn}
+ onUpdate={() => {
+ // 테이블 데이터를 새로고침하는 로직
+ // 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용
+ const meta = table.options.meta as any;
+ meta?.refreshData?.();
+ }}
+ />
+ ),
+ size: 80,
+ }; \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx
index 5f5efcb4..eaf00660 100644
--- a/lib/rfq-last/table/rfq-table-columns.tsx
+++ b/lib/rfq-last/table/rfq-table-columns.tsx
@@ -18,6 +18,7 @@ import { DataTableRowAction } from "@/types/table";
import { format, differenceInDays } from "date-fns";
import { ko } from "date-fns/locale";
import { useRouter } from "next/navigation";
+import { RfqSealToggleCell } from "./rfq-seal-toggle-cell";
type NextRouter = ReturnType<typeof useRouter>;
@@ -120,18 +121,18 @@ export function getRfqColumns({
{
accessorKey: "rfqSealedYn",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />,
- cell: ({ row }) => {
- const isSealed = row.original.rfqSealedYn;
- return (
- <div className="flex justify-center">
- {isSealed ? (
- <Lock className="h-4 w-4 text-red-500" />
- ) : (
- <LockOpen className="h-4 w-4 text-gray-400" />
- )}
- </div>
- );
- },
+ cell: ({ row, table }) => (
+ <RfqSealToggleCell
+ rfqId={row.original.id}
+ isSealed={row.original.rfqSealedYn}
+ onUpdate={() => {
+ // 테이블 데이터를 새로고침하는 로직
+ // 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용
+ const meta = table.options.meta as any;
+ meta?.refreshData?.();
+ }}
+ />
+ ),
size: 80,
},
@@ -453,18 +454,18 @@ export function getRfqColumns({
{
accessorKey: "rfqSealedYn",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />,
- cell: ({ row }) => {
- const isSealed = row.original.rfqSealedYn;
- return (
- <div className="flex justify-center">
- {isSealed ? (
- <Lock className="h-4 w-4 text-red-500" />
- ) : (
- <LockOpen className="h-4 w-4 text-gray-400" />
- )}
- </div>
- );
- },
+ cell: ({ row, table }) => (
+ <RfqSealToggleCell
+ rfqId={row.original.id}
+ isSealed={row.original.rfqSealedYn}
+ onUpdate={() => {
+ // 테이블 데이터를 새로고침하는 로직
+ // 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용
+ const meta = table.options.meta as any;
+ meta?.refreshData?.();
+ }}
+ />
+ ),
size: 80,
},
@@ -815,18 +816,18 @@ export function getRfqColumns({
{
accessorKey: "rfqSealedYn",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />,
- cell: ({ row }) => {
- const isSealed = row.original.rfqSealedYn;
- return (
- <div className="flex justify-center">
- {isSealed ? (
- <Lock className="h-4 w-4 text-red-500" />
- ) : (
- <LockOpen className="h-4 w-4 text-gray-400" />
- )}
- </div>
- );
- },
+ cell: ({ row, table }) => (
+ <RfqSealToggleCell
+ rfqId={row.original.id}
+ isSealed={row.original.rfqSealedYn}
+ onUpdate={() => {
+ // 테이블 데이터를 새로고침하는 로직
+ // 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용
+ const meta = table.options.meta as any;
+ meta?.refreshData?.();
+ }}
+ />
+ ),
size: 80,
},
diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
index 9b696cbd..91b2798f 100644
--- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
+++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
@@ -2,7 +2,7 @@
import * as React from "react";
import { type Table } from "@tanstack/react-table";
-import { Download, RefreshCw, Plus } from "lucide-react";
+import { Download, RefreshCw, Plus, Lock, LockOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -12,8 +12,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { toast } from "sonner";
import { RfqsLastView } from "@/db/schema";
import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog";
+import { sealMultipleRfqs, unsealMultipleRfqs } from "../service";
interface RfqTableToolbarActionsProps {
table: Table<RfqsLastView>;
@@ -27,6 +39,43 @@ export function RfqTableToolbarActions({
rfqCategory = "itb",
}: RfqTableToolbarActionsProps) {
const [isExporting, setIsExporting] = React.useState(false);
+ const [isSealing, setIsSealing] = React.useState(false);
+ const [sealDialogOpen, setSealDialogOpen] = React.useState(false);
+ const [sealAction, setSealAction] = React.useState<"seal" | "unseal">("seal");
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const selectedRfqIds = selectedRows.map(row => row.original.id);
+
+ // 선택된 항목들의 밀봉 상태 확인
+ const sealedCount = selectedRows.filter(row => row.original.rfqSealedYn).length;
+ const unsealedCount = selectedRows.filter(row => !row.original.rfqSealedYn).length;
+
+ const handleSealAction = React.useCallback(async (action: "seal" | "unseal") => {
+ setSealAction(action);
+ setSealDialogOpen(true);
+ }, []);
+
+ const confirmSealAction = React.useCallback(async () => {
+ setIsSealing(true);
+ try {
+ const result = sealAction === "seal"
+ ? await sealMultipleRfqs(selectedRfqIds)
+ : await unsealMultipleRfqs(selectedRfqIds);
+
+ if (result.success) {
+ toast.success(result.message);
+ table.toggleAllRowsSelected(false); // 선택 해제
+ onRefresh?.(); // 데이터 새로고침
+ } else {
+ toast.error(result.error);
+ }
+ } catch (error) {
+ toast.error("작업 중 오류가 발생했습니다.");
+ } finally {
+ setIsSealing(false);
+ setSealDialogOpen(false);
+ }
+ }, [sealAction, selectedRfqIds, table, onRefresh]);
const handleExportCSV = React.useCallback(async () => {
setIsExporting(true);
@@ -36,6 +85,7 @@ export function RfqTableToolbarActions({
return {
"RFQ 코드": original.rfqCode || "",
"상태": original.status || "",
+ "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉",
"프로젝트 코드": original.projectCode || "",
"프로젝트명": original.projectName || "",
"자재코드": original.itemCode || "",
@@ -89,6 +139,7 @@ export function RfqTableToolbarActions({
return {
"RFQ 코드": original.rfqCode || "",
"상태": original.status || "",
+ "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉",
"프로젝트 코드": original.projectCode || "",
"프로젝트명": original.projectName || "",
"자재코드": original.itemCode || "",
@@ -115,48 +166,143 @@ export function RfqTableToolbarActions({
}, [table]);
return (
- <div className="flex items-center gap-2">
- {onRefresh && (
- <Button
- variant="outline"
- size="sm"
- onClick={onRefresh}
- className="h-8 px-2 lg:px-3"
- >
- <RefreshCw className="mr-2 h-4 w-4" />
- 새로고침
- </Button>
- )}
-
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
+ <>
+ <div className="flex items-center gap-2">
+ {onRefresh && (
<Button
variant="outline"
size="sm"
+ onClick={onRefresh}
className="h-8 px-2 lg:px-3"
- disabled={isExporting}
>
- <Download className="mr-2 h-4 w-4" />
- {isExporting ? "내보내는 중..." : "내보내기"}
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 새로고침
</Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={handleExportCSV}>
- 전체 데이터 내보내기
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={handleExportSelected}
- disabled={table.getFilteredSelectedRowModel().rows.length === 0}
- >
- 선택한 항목 내보내기 ({table.getFilteredSelectedRowModel().rows.length}개)
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
-
- {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */}
- {rfqCategory === "general" && (
- <CreateGeneralRfqDialog onSuccess={onRefresh} />
- ) }
- </div>
+ )}
+
+ {/* 견적 밀봉/해제 버튼 */}
+ {selectedRfqIds.length > 0 && (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 px-2 lg:px-3"
+ disabled={isSealing}
+ >
+ <Lock className="mr-2 h-4 w-4" />
+ 견적 밀봉
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => handleSealAction("seal")}
+ disabled={unsealedCount === 0}
+ >
+ <Lock className="mr-2 h-4 w-4" />
+ 선택 항목 밀봉 ({unsealedCount}개)
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => handleSealAction("unseal")}
+ disabled={sealedCount === 0}
+ >
+ <LockOpen className="mr-2 h-4 w-4" />
+ 선택 항목 밀봉 해제 ({sealedCount}개)
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">
+ 전체 {selectedRfqIds.length}개 선택됨
+ </div>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )}
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 px-2 lg:px-3"
+ disabled={isExporting}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ {isExporting ? "내보내는 중..." : "내보내기"}
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={handleExportCSV}>
+ 전체 데이터 내보내기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={handleExportSelected}
+ disabled={table.getFilteredSelectedRowModel().rows.length === 0}
+ >
+ 선택한 항목 내보내기 ({table.getFilteredSelectedRowModel().rows.length}개)
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */}
+ {rfqCategory === "general" && (
+ <CreateGeneralRfqDialog onSuccess={onRefresh} />
+ )}
+ </div>
+
+ {/* 밀봉 확인 다이얼로그 */}
+ <AlertDialog open={sealDialogOpen} onOpenChange={setSealDialogOpen}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>
+ {sealAction === "seal" ? "견적 밀봉 확인" : "견적 밀봉 해제 확인"}
+ </AlertDialogTitle>
+ <AlertDialogDescription>
+ {sealAction === "seal"
+ ? `선택한 ${unsealedCount}개의 견적을 밀봉하시겠습니까? 밀봉된 견적은 업체에서 수정할 수 없습니다.`
+ : `선택한 ${sealedCount}개의 견적 밀봉을 해제하시겠습니까? 밀봉이 해제되면 업체에서 견적을 수정할 수 있습니다.`}
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isSealing}>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={confirmSealAction}
+ disabled={isSealing}
+ className={sealAction === "seal" ? "bg-red-600 hover:bg-red-700" : ""}
+ >
+ {isSealing ? "처리 중..." : "확인"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
);
+}
+
+// CSV 내보내기 유틸리티 함수
+function exportTableToCSV({ data, filename }: { data: any[]; filename: string }) {
+ if (!data || data.length === 0) {
+ console.warn("No data to export");
+ return;
+ }
+
+ const headers = Object.keys(data[0]);
+ const csvContent = [
+ headers.join(","),
+ ...data.map(row =>
+ headers.map(header => {
+ const value = row[header];
+ // 값에 쉼표, 줄바꿈, 따옴표가 있으면 따옴표로 감싸기
+ if (typeof value === "string" && (value.includes(",") || value.includes("\n") || value.includes('"'))) {
+ return `"${value.replace(/"/g, '""')}"`;
+ }
+ return value;
+ }).join(",")
+ )
+ ].join("\n");
+
+ const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" });
+ const link = document.createElement("a");
+ link.href = URL.createObjectURL(blob);
+ link.download = filename;
+ link.click();
+ URL.revokeObjectURL(link.href);
} \ No newline at end of file
diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
index c146e42b..34259d37 100644
--- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
+++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useState } from "react"
+import { useState,useEffect } from "react"
import { useForm, FormProvider } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
@@ -163,18 +163,74 @@ export default function VendorResponseEditor({
const methods = useForm<VendorResponseFormData>({
resolver: zodResolver(vendorResponseSchema),
- defaultValues
+ defaultValues,
+ mode: 'onChange' // 추가: 실시간 validation
})
+ const { formState: { errors, isValid } } = methods
+
+ useEffect(() => {
+ if (Object.keys(errors).length > 0) {
+ console.log('Validation errors:', errors)
+ }
+ }, [errors])
+
+
+
+ const handleFormSubmit = (isSubmit: boolean = false) => {
+ // 임시저장일 경우 validation 없이 바로 저장
+ if (!isSubmit) {
+ const formData = methods.getValues()
+ onSubmit(formData, false)
+ return
+ }
+
+ // 제출일 경우에만 validation 수행
+ methods.handleSubmit(
+ (data) => onSubmit(data, isSubmit),
+ (errors) => {
+ console.error('Form validation errors:', errors)
+
+ // 첫 번째 에러 필드로 포커스 이동
+ const firstErrorField = Object.keys(errors)[0]
+ if (firstErrorField) {
+ // 어느 탭에 에러가 있는지 확인
+ if (firstErrorField.startsWith('vendor') &&
+ !firstErrorField.startsWith('vendorFirst') &&
+ !firstErrorField.startsWith('vendorSparepart')) {
+ setActiveTab('terms')
+ } else if (firstErrorField === 'quotationItems') {
+ setActiveTab('items')
+ }
+
+ // 구체적인 에러 메시지 표시
+ if (errors.quotationItems) {
+ toast.error("견적 품목 정보를 확인해주세요. 모든 품목의 단가와 총액을 입력해야 합니다.")
+ } else {
+ toast.error("입력 정보를 확인해주세요.")
+ }
+ }
+ }
+ )()
+ }
+
const onSubmit = async (data: VendorResponseFormData, isSubmit: boolean = false) => {
+ console.log('onSubmit called with:', { data, isSubmit }) // 디버깅용
+
setLoading(true)
setUploadProgress(0)
try {
const formData = new FormData()
+ const fileMetadata = attachments.map((file: any) => ({
+ attachmentType: file.attachmentType || "기타",
+ description: file.description || ""
+ }))
+
+
// 기본 데이터 추가
- formData.append('data', JSON.stringify({
+ const submitData = {
...data,
rfqsLastId: rfq.id,
rfqLastDetailsId: rfqDetail.id,
@@ -183,69 +239,76 @@ export default function VendorResponseEditor({
submittedAt: isSubmit ? new Date().toISOString() : null,
submittedBy: isSubmit ? userId : null,
totalAmount: data.quotationItems.reduce((sum, item) => sum + item.totalPrice, 0),
- updatedBy: userId
- }))
+ updatedBy: userId,
+ fileMetadata
+ }
+
+ console.log('Submitting data:', submitData) // 디버깅용
+
+ formData.append('data', JSON.stringify(submitData))
// 첨부파일 추가
attachments.forEach((file, index) => {
formData.append(`attachments`, file)
})
- // const response = await fetch(`/api/partners/rfq-last/${rfq.id}/response`, {
- // method: existingResponse ? 'PUT' : 'POST',
- // body: formData
- // })
-
- // if (!response.ok) {
- // throw new Error('응답 저장에 실패했습니다.')
- // }
-
- // XMLHttpRequest 사용하여 업로드 진행률 추적
- const xhr = new XMLHttpRequest()
-
- // Promise로 감싸서 async/await 사용 가능하게
- const uploadPromise = new Promise((resolve, reject) => {
- // 업로드 진행률 이벤트
- xhr.upload.addEventListener('progress', (event) => {
- if (event.lengthComputable) {
- const percentComplete = Math.round((event.loaded / event.total) * 100)
- setUploadProgress(percentComplete)
- }
- })
-
- // 완료 이벤트
- xhr.addEventListener('load', () => {
- if (xhr.status >= 200 && xhr.status < 300) {
- setUploadProgress(100)
- resolve(JSON.parse(xhr.responseText))
- } else {
- reject(new Error('응답 저장에 실패했습니다.'))
+ // XMLHttpRequest 사용하여 업로드 진행률 추적
+ const xhr = new XMLHttpRequest()
+
+ const uploadPromise = new Promise((resolve, reject) => {
+ xhr.upload.addEventListener('progress', (event) => {
+ if (event.lengthComputable) {
+ const percentComplete = Math.round((event.loaded / event.total) * 100)
+ setUploadProgress(percentComplete)
+ }
+ })
+
+ xhr.addEventListener('load', () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ setUploadProgress(100)
+ try {
+ const response = JSON.parse(xhr.responseText)
+ resolve(response)
+ } catch (e) {
+ console.error('Response parsing error:', e)
+ reject(new Error('응답 파싱 실패'))
}
- })
-
- // 에러 이벤트
- xhr.addEventListener('error', () => {
- reject(new Error('네트워크 오류가 발생했습니다.'))
- })
-
- // 요청 전송
- xhr.open(existingResponse ? 'PUT' : 'POST', `/api/partners/rfq-last/${rfq.id}/response`)
- xhr.send(formData)
+ } else {
+ console.error('Server error:', xhr.status, xhr.responseText)
+ reject(new Error(`서버 오류: ${xhr.status}`))
+ }
+ })
+
+ xhr.addEventListener('error', () => {
+ console.error('Network error')
+ reject(new Error('네트워크 오류가 발생했습니다.'))
})
+
+ // 요청 전송
+ const method = existingResponse ? 'PUT' : 'POST'
+ const url = `/api/partners/rfq-last/${rfq.id}/response`
+
+ console.log(`Sending ${method} request to ${url}`) // 디버깅용
- await uploadPromise
+ xhr.open(method, url)
+ xhr.send(formData)
+ })
+
+ await uploadPromise
toast.success(isSubmit ? "견적서가 제출되었습니다." : "견적서가 저장되었습니다.")
router.push('/partners/rfq-last')
router.refresh()
} catch (error) {
- console.error('Error:', error)
- toast.error("오류가 발생했습니다.")
+ console.error('Submit error:', error) // 더 상세한 에러 로깅
+ toast.error(error instanceof Error ? error.message : "오류가 발생했습니다.")
} finally {
setLoading(false)
+ setUploadProgress(0)
}
}
+
const totalAmount = methods.watch('quotationItems')?.reduce(
(sum, item) => sum + (item.totalPrice || 0), 0
) || 0
@@ -256,7 +319,10 @@ export default function VendorResponseEditor({
return (
<FormProvider {...methods}>
- <form onSubmit={methods.handleSubmit((data) => onSubmit(data, false))}>
+ <form onSubmit={(e) => {
+ e.preventDefault() // 기본 submit 동작 방지
+ handleFormSubmit(false)
+ }}>
<div className="space-y-6">
{/* 헤더 정보 */}
<RfqInfoHeader rfq={rfq} rfqDetail={rfqDetail} vendor={vendor} />
@@ -293,92 +359,92 @@ export default function VendorResponseEditor({
</CardDescription>
</CardHeader>
<CardContent>
- {basicContracts.length > 0 ? (
- <div className="space-y-4">
- {/* 계약 목록 - 그리드 레이아웃 */}
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
- {basicContracts.map((contract) => (
- <div
- key={contract.id}
- className="p-3 border rounded-lg bg-card hover:bg-muted/50 transition-colors"
- >
- <div className="flex items-start gap-2">
- <div className="p-1.5 bg-primary/10 rounded">
- <Shield className="h-3.5 w-3.5 text-primary" />
- </div>
- <div className="flex-1 min-w-0">
- <h4 className="font-medium text-sm truncate" title={contract.templateName}>
- {contract.templateName}
- </h4>
- <Badge
- variant={contract.signedAt ? "success" : "warning"}
- className="text-xs mt-1.5"
- >
- {contract.signedAt ? (
- <>
- <CheckCircle className="h-3 w-3 mr-1" />
- 서명완료
- </>
+ {basicContracts.length > 0 ? (
+ <div className="space-y-4">
+ {/* 계약 목록 - 그리드 레이아웃 */}
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
+ {basicContracts.map((contract) => (
+ <div
+ key={contract.id}
+ className="p-3 border rounded-lg bg-card hover:bg-muted/50 transition-colors"
+ >
+ <div className="flex items-start gap-2">
+ <div className="p-1.5 bg-primary/10 rounded">
+ <Shield className="h-3.5 w-3.5 text-primary" />
+ </div>
+ <div className="flex-1 min-w-0">
+ <h4 className="font-medium text-sm truncate" title={contract.templateName}>
+ {contract.templateName}
+ </h4>
+ <Badge
+ variant={contract.signedAt ? "success" : "warning"}
+ className="text-xs mt-1.5"
+ >
+ {contract.signedAt ? (
+ <>
+ <CheckCircle className="h-3 w-3 mr-1" />
+ 서명완료
+ </>
+ ) : (
+ <>
+ <Clock className="h-3 w-3 mr-1" />
+ 서명대기
+ </>
+ )}
+ </Badge>
+ <p className="text-xs text-muted-foreground mt-1">
+ {contract.signedAt
+ ? `${formatDate(new Date(contract.signedAt))}`
+ : contract.deadline
+ ? `~${formatDate(new Date(contract.deadline))}`
+ : '마감일 없음'}
+ </p>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 서명 상태 요약 및 액션 */}
+ {basicContracts.some(contract => !contract.signedAt) ? (
+ <div className="flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg">
+ <div className="flex items-center gap-2">
+ <AlertCircle className="h-4 w-4 text-amber-600" />
+ <div>
+ <p className="text-sm font-medium">
+ 서명 대기: {basicContracts.filter(c => !c.signedAt).length}/{basicContracts.length}개
+ </p>
+ <p className="text-xs text-muted-foreground">
+ 견적서 제출 전 모든 계약서 서명 필요
+ </p>
+ </div>
+ </div>
+ <Button
+ type="button"
+ size="sm"
+ onClick={() => router.push(`/partners/basic-contract`)}
+ >
+ 서명하기
+ </Button>
+ </div>
+ ) : (
+ <Alert className="border-green-200 bg-green-50 dark:bg-green-950/20">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <AlertDescription className="text-sm">
+ 모든 기본계약 서명 완료
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
) : (
- <>
- <Clock className="h-3 w-3 mr-1" />
- 서명대기
- </>
+ <div className="text-center py-8">
+ <FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
+ <p className="text-muted-foreground">
+ 이 RFQ에 요청된 기본계약이 없습니다
+ </p>
+ </div>
)}
- </Badge>
- <p className="text-xs text-muted-foreground mt-1">
- {contract.signedAt
- ? `${formatDate(new Date(contract.signedAt))}`
- : contract.deadline
- ? `~${formatDate(new Date(contract.deadline))}`
- : '마감일 없음'}
- </p>
- </div>
- </div>
- </div>
- ))}
- </div>
-
- {/* 서명 상태 요약 및 액션 */}
- {basicContracts.some(contract => !contract.signedAt) ? (
- <div className="flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg">
- <div className="flex items-center gap-2">
- <AlertCircle className="h-4 w-4 text-amber-600" />
- <div>
- <p className="text-sm font-medium">
- 서명 대기: {basicContracts.filter(c => !c.signedAt).length}/{basicContracts.length}개
- </p>
- <p className="text-xs text-muted-foreground">
- 견적서 제출 전 모든 계약서 서명 필요
- </p>
- </div>
- </div>
- <Button
- type="button"
- size="sm"
- onClick={() => router.push(`/partners/basic-contract`)}
- >
- 서명하기
- </Button>
- </div>
- ) : (
- <Alert className="border-green-200 bg-green-50 dark:bg-green-950/20">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <AlertDescription className="text-sm">
- 모든 기본계약 서명 완료
- </AlertDescription>
- </Alert>
- )}
- </div>
- ) : (
- <div className="text-center py-8">
- <FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
- <p className="text-muted-foreground">
- 이 RFQ에 요청된 기본계약이 없습니다
- </p>
- </div>
- )}
-</CardContent>
+ </CardContent>
</Card>
</TabsContent>
@@ -429,8 +495,9 @@ export default function VendorResponseEditor({
취소
</Button>
<Button
- type="submit"
+ type="button" // submit에서 button으로 변경
variant="secondary"
+ onClick={() => handleFormSubmit(false)} // 직접 핸들러 호출
disabled={loading}
>
{loading ? (
@@ -448,7 +515,7 @@ export default function VendorResponseEditor({
<Button
type="button"
variant="default"
- onClick={methods.handleSubmit((data) => onSubmit(data, true))}
+ onClick={() => handleFormSubmit(true)} // 직접 핸들러 호출
disabled={loading || !allContractsSigned}
>
{!allContractsSigned ? (
diff --git a/lib/rfq-last/vendor-response/service.ts b/lib/rfq-last/vendor-response/service.ts
index 7de3ae58..04cc5234 100644
--- a/lib/rfq-last/vendor-response/service.ts
+++ b/lib/rfq-last/vendor-response/service.ts
@@ -7,7 +7,7 @@ import { and, or, eq, desc, asc, count, ilike, inArray } from "drizzle-orm";
import {
rfqsLastView,
rfqLastDetails,
- rfqLastVendorResponses,
+ rfqLastVendorResponses,vendorQuotationView,
type RfqsLastView
} from "@/db/schema";
import { filterColumns } from "@/lib/filter-columns";
@@ -26,25 +26,6 @@ export type VendorQuotationStatus =
| "최종확정" // 최종 확정됨
| "취소" // 취소됨
-// 벤더 견적 뷰 타입 확장
-export interface VendorQuotationView extends RfqsLastView {
- // 벤더 응답 정보
- responseStatus?: VendorQuotationStatus;
- displayStatus?:string;
- responseVersion?: number;
- submittedAt?: Date;
- totalAmount?: number;
- vendorCurrency?: string;
-
- // 벤더별 조건
- vendorPaymentTerms?: string;
- vendorIncoterms?: string;
- vendorDeliveryDate?: Date;
-
- participationStatus: "미응답" | "참여" | "불참" | null
- participationRepliedAt: Date | null
- nonParticipationReason: string | null
-}
/**
* 벤더별 RFQ 목록 조회
@@ -66,28 +47,9 @@ export async function getVendorQuotationsLast(
const perPage = input.perPage || 10;
const offset = (page - 1) * perPage;
- // 1. 먼저 벤더가 포함된 RFQ ID들 조회
- const vendorRfqIds = await db
- .select({ rfqsLastId: rfqLastDetails.rfqsLastId })
- .from(rfqLastDetails)
- .where(
- and(
- eq(rfqLastDetails.vendorsId, numericVendorId),
- eq(rfqLastDetails.isLatest, true)
- )
- );
-
-
- const rfqIds = vendorRfqIds.map(r => r.rfqsLastId).filter(id => id !== null);
-
- if (rfqIds.length === 0) {
- return { data: [], pageCount: 0 };
- }
-
- // 2. 필터링 설정
- // advancedTable 모드로 where 절 구성
+ // 필터링 설정
const advancedWhere = filterColumns({
- table: rfqsLastView,
+ table: vendorQuotationView,
filters: input.filters,
joinOperator: input.joinOperator,
});
@@ -97,148 +59,55 @@ export async function getVendorQuotationsLast(
if (input.search) {
const s = `%${input.search}%`;
globalWhere = or(
- ilike(rfqsLastView.rfqCode, s),
- ilike(rfqsLastView.rfqTitle, s),
- ilike(rfqsLastView.itemName, s),
- ilike(rfqsLastView.projectName, s),
- ilike(rfqsLastView.packageName, s),
- ilike(rfqsLastView.status, s)
+ ilike(vendorQuotationView.rfqCode, s),
+ ilike(vendorQuotationView.rfqTitle, s),
+ ilike(vendorQuotationView.itemName, s),
+ ilike(vendorQuotationView.projectName, s),
+ ilike(vendorQuotationView.packageName, s),
+ ilike(vendorQuotationView.status, s),
+ ilike(vendorQuotationView.displayStatus, s)
);
}
- // RFQ ID 조건 (벤더가 포함된 RFQ만)
- const rfqIdWhere = inArray(rfqsLastView.id, rfqIds);
+ // 벤더 ID 조건 (필수)
+ const vendorIdWhere = eq(vendorQuotationView.vendorId, numericVendorId);
// 모든 조건 결합
- let whereConditions = [rfqIdWhere]; // 필수 조건
+ let whereConditions = [vendorIdWhere];
if (advancedWhere) whereConditions.push(advancedWhere);
if (globalWhere) whereConditions.push(globalWhere);
- // 최종 조건
const finalWhere = and(...whereConditions);
- // 3. 정렬 설정
+ // 정렬 설정
const orderBy = input.sort && input.sort.length > 0
? input.sort.map((item) => {
- // @ts-ignore - 동적 속성 접근
- return item.desc ? desc(rfqsLastView[item.id]) : asc(rfqsLastView[item.id]);
+ // @ts-ignore
+ return item.desc ? desc(vendorQuotationView[item.id]) : asc(vendorQuotationView[item.id]);
})
- : [desc(rfqsLastView.updatedAt)];
+ : [desc(vendorQuotationView.updatedAt)];
- // 4. 메인 쿼리 실행
+ // 메인 쿼리 실행 - 이제 한 번의 쿼리로 모든 데이터를 가져옴
const quotations = await db
.select()
- .from(rfqsLastView)
+ .from(vendorQuotationView)
.where(finalWhere)
.orderBy(...orderBy)
.limit(perPage)
.offset(offset);
- // 5. 각 RFQ에 대한 벤더 응답 정보 조회
- const quotationsWithResponse = await Promise.all(
- quotations.map(async (rfq) => {
- // 벤더 응답 정보 조회
- const response = await db.query.rfqLastVendorResponses.findFirst({
- where: and(
- eq(rfqLastVendorResponses.rfqsLastId, rfq.id),
- eq(rfqLastVendorResponses.vendorId, numericVendorId),
- eq(rfqLastVendorResponses.isLatest, true)
- ),
- columns: {
- status: true,
- responseVersion: true,
- submittedAt: true,
- totalAmount: true,
- vendorCurrency: true,
- vendorPaymentTermsCode: true,
- vendorIncotermsCode: true,
- vendorDeliveryDate: true,
- participationStatus: true,
- participationRepliedAt: true,
- nonParticipationReason: true,
- }
- });
-
- // 벤더 상세 정보 조회
- const detail = await db.query.rfqLastDetails.findFirst({
- where: and(
- eq(rfqLastDetails.rfqsLastId, rfq.id),
- eq(rfqLastDetails.vendorsId, numericVendorId),
- eq(rfqLastDetails.isLatest, true)
- ),
- columns: {
- id: true, // rfqLastDetailsId 필요
- emailSentAt: true,
- emailStatus: true,
- shortList: true,
- }
- });
-
- // 표시할 상태 결정 (새로운 로직)
- let displayStatus: string | null = null;
-
- if (response) {
- // 응답 레코드가 있는 경우
- if (response.participationStatus === "불참") {
- displayStatus = "불참";
- } else if (response.participationStatus === "참여") {
- // 참여한 경우 실제 작업 상태 표시
- displayStatus = response.status || "작성중";
- } else {
- // participationStatus가 없거나 "미응답"인 경우
- displayStatus = "미응답";
- }
- } else {
- // 응답 레코드가 없는 경우
- if (detail?.emailSentAt) {
- displayStatus = "미응답"; // 초대는 받았지만 응답 안함
- } else {
- displayStatus = null; // 아직 초대도 안됨
- }
- }
-
- return {
- ...rfq,
- // 새로운 상태 체계
- displayStatus, // UI에서 표시할 통합 상태
-
- // 참여 관련 정보
- participationStatus: response?.participationStatus || "미응답",
- participationRepliedAt: response?.participationRepliedAt,
- nonParticipationReason: response?.nonParticipationReason,
-
- // 견적 작업 상태 (참여한 경우에만 의미 있음)
- responseStatus: response?.status,
- responseVersion: response?.responseVersion,
- submittedAt: response?.submittedAt,
- totalAmount: response?.totalAmount,
- vendorCurrency: response?.vendorCurrency,
- vendorPaymentTerms: response?.vendorPaymentTermsCode,
- vendorIncoterms: response?.vendorIncotermsCode,
- vendorDeliveryDate: response?.vendorDeliveryDate,
-
- // 초대 관련 정보
- rfqLastDetailsId: detail?.id, // 참여 결정 시 필요
- emailSentAt: detail?.emailSentAt,
- emailStatus: detail?.emailStatus,
- shortList: detail?.shortList,
- } as VendorQuotationView;
- })
- );
-
- // 6. 전체 개수 조회
+ // 전체 개수 조회
const { totalCount } = await db
.select({ totalCount: count() })
- .from(rfqsLastView)
+ .from(vendorQuotationView)
.where(finalWhere)
.then(rows => rows[0]);
// 페이지 수 계산
const pageCount = Math.ceil(Number(totalCount) / perPage);
-
return {
- data: quotationsWithResponse,
+ data: quotations,
pageCount
};
} catch (err) {
diff --git a/lib/rfq-last/vendor-response/validations.ts b/lib/rfq-last/vendor-response/validations.ts
index 033154c2..5834bbf6 100644
--- a/lib/rfq-last/vendor-response/validations.ts
+++ b/lib/rfq-last/vendor-response/validations.ts
@@ -7,7 +7,7 @@ import { createSearchParamsCache,
import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { RfqsLastView } from "@/db/schema";
+import { VendorQuotationView } from "@/db/schema";
@@ -15,7 +15,7 @@ export const searchParamsVendorRfqCache = createSearchParamsCache({
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<RfqsLastView>().withDefault([
+ sort: getSortingStateParser<VendorQuotationView>().withDefault([
{ id: "updatedAt", desc: true },
]),
diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx
index 144c6c43..a7135ea5 100644
--- a/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx
+++ b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx
@@ -27,8 +27,8 @@ import {
} from "@/components/ui/tooltip"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { useRouter } from "next/navigation"
-import type { VendorQuotationView } from "./service"
import { ParticipationDialog } from "./participation-dialog"
+import { VendorQuotationView } from "@/db/schema"
// 통합 상태 배지 컴포넌트 (displayStatus 사용)
function DisplayStatusBadge({ status }: { status: string | null }) {
diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx
index 683a0318..2e4975f1 100644
--- a/lib/rfq-last/vendor-response/vendor-quotations-table.tsx
+++ b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx
@@ -12,9 +12,9 @@ import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { useRouter } from "next/navigation"
import { getColumns } from "./vendor-quotations-table-columns"
-import type { VendorQuotationView } from "./service"
import { RfqAttachmentsDialog } from "./rfq-attachments-dialog";
import { RfqItemsDialog } from "./rfq-items-dialog";
+import { VendorQuotationView } from "@/db/schema"
interface VendorQuotationsTableLastProps {
promises: Promise<[{ data: VendorQuotationView[], pageCount: number }]>
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index 830fd448..d451b2ba 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -27,7 +27,9 @@ import {
Info,
Loader2,
Router,
- Shield
+ Shield,
+ CheckSquare,
+ GitCompare
} from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
@@ -59,6 +61,7 @@ import {
getRfqSendData,
getSelectedVendorsWithEmails,
sendRfqToVendors,
+ updateShortList,
type RfqSendData,
type VendorEmailInfo
} from "../service"
@@ -278,7 +281,7 @@ export function RfqVendorTable({
});
const [editContractVendor, setEditContractVendor] = React.useState<any | null>(null);
-
+ const [isUpdatingShortList, setIsUpdatingShortList] = React.useState(false);
const router = useRouter()
@@ -290,6 +293,51 @@ export function RfqVendorTable({
console.log(mergedData, "mergedData")
+ // Short List 확정 핸들러
+ const handleShortListConfirm = React.useCallback(async () => {
+
+ try {
+ setIsUpdatingShortList(true);
+
+ const vendorIds = selectedRows
+ .map(vendor => vendor.vendorId)
+ .filter(id => id != null);
+
+ const result = await updateShortList(rfqId, vendorIds, true);
+
+ if (result.success) {
+ toast.success(`${result.updatedCount}개 벤더를 Short List로 확정했습니다.`);
+ setSelectedRows([]);
+ router.refresh();
+ }
+ } catch (error) {
+ console.error("Short List 확정 실패:", error);
+ toast.error("Short List 확정에 실패했습니다.");
+ } finally {
+ setIsUpdatingShortList(false);
+ }
+ }, [selectedRows, rfqId, router]);
+
+ // 견적 비교 핸들러
+ const handleQuotationCompare = React.useCallback(() => {
+ const vendorsWithQuotation = selectedRows.filter(row =>
+ row.response?.submission?.submittedAt
+ );
+
+ if (vendorsWithQuotation.length < 2) {
+ toast.warning("비교를 위해 최소 2개 이상의 견적서가 필요합니다.");
+ return;
+ }
+
+ // 견적 비교 페이지로 이동 또는 모달 열기
+ const vendorIds = vendorsWithQuotation
+ .map(v => v.vendorId)
+ .filter(id => id != null)
+ .join(',');
+
+ router.push(`/evcp/rfq-last/${rfqId}/compare?vendors=${vendorIds}`);
+ }, [selectedRows, rfqId, router]);
+
// 일괄 발송 핸들러
const handleBulkSend = React.useCallback(async () => {
if (selectedRows.length === 0) {
@@ -302,6 +350,7 @@ export function RfqVendorTable({
// 선택된 벤더 ID들 추출
const selectedVendorIds = selectedRows
+ .filter(v=>v.shortList)
.map(row => row.vendorId)
.filter(id => id != null);
@@ -1142,65 +1191,117 @@ export function RfqVendorTable({
}, [selectedRows]);
// 추가 액션 버튼들
- const additionalActions = React.useMemo(() => (
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsAddDialogOpen(true)}
- disabled={isLoadingSendData}
- >
- <Plus className="h-4 w-4 mr-2" />
- 벤더 추가
- </Button>
- {selectedRows.length > 0 && (
- <>
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsBatchUpdateOpen(true)}
- disabled={isLoadingSendData}
- >
- <Settings2 className="h-4 w-4 mr-2" />
- 정보 일괄 입력 ({selectedRows.length})
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkSend}
- disabled={isLoadingSendData || selectedRows.length === 0}
- >
- {isLoadingSendData ? (
- <>
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
- 데이터 준비중...
- </>
- ) : (
- <>
- <Send className="h-4 w-4 mr-2" />
- RFQ 발송 ({selectedRows.length})
- </>
- )}
- </Button>
- </>
- )}
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- setIsRefreshing(true);
- setTimeout(() => {
- setIsRefreshing(false);
- toast.success("데이터를 새로고침했습니다.");
- }, 1000);
- }}
- disabled={isRefreshing || isLoadingSendData}
- >
- <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
- 새로고침
- </Button>
- </div>
- ), [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend]);
+ const additionalActions = React.useMemo(() => {
+
+ // 참여 의사가 있는 선택된 벤더 수 계산
+ const participatingCount = selectedRows.length;
+ const shortListCount = selectedRows.filter(v=>v.shortList).length;
+
+ // 견적서가 있는 선택된 벤더 수 계산
+ const quotationCount = selectedRows.filter(row =>
+ row.response?.submission?.submittedAt
+ ).length;
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsAddDialogOpen(true)}
+ disabled={isLoadingSendData}
+ >
+ <Plus className="h-4 w-4 mr-2" />
+ 벤더 추가
+ </Button>
+
+ {selectedRows.length > 0 && (
+ <>
+ {/* Short List 확정 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleShortListConfirm}
+ disabled={isUpdatingShortList }
+ // className={ "border-green-500 text-green-600 hover:bg-green-50" }
+ >
+ {isUpdatingShortList ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 처리중...
+ </>
+ ) : (
+ <>
+ <CheckSquare className="h-4 w-4 mr-2" />
+ Short List 확정
+ {participatingCount > 0 && ` (${participatingCount})`}
+ </>
+ )}
+ </Button>
+
+ {/* 견적 비교 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleQuotationCompare}
+ disabled={quotationCount < 1}
+ className={quotationCount >= 2 ? "border-blue-500 text-blue-600 hover:bg-blue-50" : ""}
+ >
+ <GitCompare className="h-4 w-4 mr-2" />
+ 견적 비교
+ {quotationCount > 0 && ` (${quotationCount})`}
+ </Button>
+
+ {/* 정보 일괄 입력 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsBatchUpdateOpen(true)}
+ disabled={isLoadingSendData}
+ >
+ <Settings2 className="h-4 w-4 mr-2" />
+ 정보 일괄 입력 ({selectedRows.length})
+ </Button>
+
+ {/* RFQ 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkSend}
+ disabled={isLoadingSendData || selectedRows.length === 0}
+ >
+ {isLoadingSendData ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 데이터 준비중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4 mr-2" />
+ RFQ 발송 ({shortListCount})
+ </>
+ )}
+ </Button>
+ </>
+ )}
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setIsRefreshing(true);
+ setTimeout(() => {
+ setIsRefreshing(false);
+ toast.success("데이터를 새로고침했습니다.");
+ }, 1000);
+ }}
+ disabled={isRefreshing || isLoadingSendData}
+ >
+ <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
+ 새로고침
+ </Button>
+ </div>
+ );
+ }, [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend, handleShortListConfirm, handleQuotationCompare, isUpdatingShortList]);
return (
<>
diff --git a/lib/soap/ecc/send/pcr-confirm.ts b/lib/soap/ecc/send/pcr-confirm.ts
index 439ec6f8..7799d007 100644
--- a/lib/soap/ecc/send/pcr-confirm.ts
+++ b/lib/soap/ecc/send/pcr-confirm.ts
@@ -43,7 +43,7 @@ export interface PCRConfirmResponse {
// 1,ZMM_PCR,PCR_REQ,M,CHAR,10,PCR 요청번호
// 2,ZMM_PCR,PCR_REQ_SEQ,M,NUMC,5,PCR 요청순번
// 3,ZMM_PCR,PCR_DEC_DATE,M,DATS,8,PCR 결정일
-// 4,ZMM_PCR,EBELN,M,CHAR,10,구매오더
+// 4,ZMM_PCR,EBELN,M,CHAR,10,구매오더(PO번호)
// 5,ZMM_PCR,EBELP,M,NUMC,5,구매오더 품번
// 6,ZMM_PCR,WAERS,,CUKY,5,PCR 통화
// 7,ZMM_PCR,PCR_NETPR,,CURR,"13,2",PCR 단가
diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts
index 760f66ac..d9046524 100644
--- a/lib/tbe-last/service.ts
+++ b/lib/tbe-last/service.ts
@@ -6,10 +6,11 @@ import db from "@/db/db";
import { and, desc, asc, eq, sql, or, isNull, isNotNull, ne, inArray } from "drizzle-orm";
import { tbeLastView, tbeDocumentsView } from "@/db/schema";
import { rfqPrItems } from "@/db/schema/rfqLast";
-import { rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments } from "@/db/schema";
+import { rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments,rfqLastTbeSessions } from "@/db/schema";
import { filterColumns } from "@/lib/filter-columns";
import { GetTBELastSchema } from "./validations";
-
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
// ==========================================
// 1. TBE 세션 목록 조회
// ==========================================
@@ -87,8 +88,8 @@ export async function getAllTBELast(input: GetTBELastSchema) {
// 2. TBE 세션 상세 조회
// ==========================================
export async function getTBESessionDetail(sessionId: number) {
- return unstable_cache(
- async () => {
+ // return unstable_cache(
+ // async () => {
// 세션 기본 정보
const [session] = await db
.select()
@@ -153,13 +154,13 @@ export async function getTBESessionDetail(sessionId: number) {
prItems,
documents: documentsWithComments,
};
- },
- [`tbe-session-${sessionId}`],
- {
- revalidate: 60,
- tags: [`tbe-session-${sessionId}`],
- }
- )();
+ // },
+ // [`tbe-session-${sessionId}`],
+ // {
+ // revalidate: 60,
+ // tags: [`tbe-session-${sessionId}`],
+ // }
+ // )();
}
// ==========================================
@@ -190,25 +191,6 @@ export async function getDocumentComments(documentReviewId: number) {
return comments;
}
-// ==========================================
-// 4. TBE 평가 결과 업데이트
-// ==========================================
-export async function updateTBEEvaluation(
- sessionId: number,
- data: {
- evaluationResult: "pass" | "conditional_pass" | "non_pass";
- conditionalRequirements?: string;
- technicalSummary?: string;
- commercialSummary?: string;
- overallRemarks?: string;
- }
-) {
- // 실제 업데이트 로직
- // await db.update(rfqLastTbeSessions)...
-
- // 캐시 무효화
- return { success: true };
-}
// ==========================================
// 5. 벤더 문서 업로드
@@ -244,4 +226,193 @@ export async function uploadVendorDocument(
.returning();
return document;
+}
+
+interface UpdateEvaluationData {
+ evaluationResult?: "Acceptable" | "Acceptable with Comment" | "Not Acceptable"
+ conditionalRequirements?: string
+ conditionsFulfilled?: boolean
+ technicalSummary?: string
+ commercialSummary?: string
+ overallRemarks?: string
+ approvalRemarks?: string
+ status?: "준비중" | "진행중" | "검토중" | "보류" | "완료" | "취소"
+}
+
+export async function updateTbeEvaluation(
+ tbeSessionId: number,
+ data: UpdateEvaluationData
+) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ return { success: false, error: "인증이 필요합니다" }
+ }
+
+ const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id
+
+ // 현재 TBE 세션 조회
+ const [currentTbeSession] = await db
+ .select()
+ .from(rfqLastTbeSessions)
+ .where(eq(rfqLastTbeSessions.id, tbeSessionId))
+ .limit(1)
+
+ if (!currentTbeSession) {
+ return { success: false, error: "TBE 세션을 찾을 수 없습니다" }
+ }
+
+ // 업데이트 데이터 준비
+ const updateData: any = {
+ updatedBy: userId,
+ updatedAt: new Date()
+ }
+
+ // 평가 결과 관련 필드
+ if (data.evaluationResult !== undefined) {
+ updateData.evaluationResult = data.evaluationResult
+ }
+
+ // 조건부 승인 관련 (Acceptable with Comment인 경우)
+ if (data.evaluationResult === "Acceptable with Comment") {
+ if (data.conditionalRequirements !== undefined) {
+ updateData.conditionalRequirements = data.conditionalRequirements
+ }
+ if (data.conditionsFulfilled !== undefined) {
+ updateData.conditionsFulfilled = data.conditionsFulfilled
+ }
+ } else if (data.evaluationResult === "Acceptable") {
+ // Acceptable인 경우 조건부 필드 초기화
+ updateData.conditionalRequirements = null
+ updateData.conditionsFulfilled = true
+ } else if (data.evaluationResult === "Not Acceptable") {
+ // Not Acceptable인 경우 조건부 필드 초기화
+ updateData.conditionalRequirements = null
+ updateData.conditionsFulfilled = false
+ }
+
+ // 평가 요약 필드
+ if (data.technicalSummary !== undefined) {
+ updateData.technicalSummary = data.technicalSummary
+ }
+ if (data.commercialSummary !== undefined) {
+ updateData.commercialSummary = data.commercialSummary
+ }
+ if (data.overallRemarks !== undefined) {
+ updateData.overallRemarks = data.overallRemarks
+ }
+
+ // 승인 관련 필드
+ if (data.approvalRemarks !== undefined) {
+ updateData.approvalRemarks = data.approvalRemarks
+ updateData.approvedBy = userId
+ updateData.approvedAt = new Date()
+ }
+
+ // 상태 업데이트
+ if (data.status !== undefined) {
+ updateData.status = data.status
+
+ // 완료 상태로 변경 시 종료일 설정
+ if (data.status === "완료") {
+ updateData.actualEndDate = new Date()
+ }
+ }
+
+ // TBE 세션 업데이트
+ const [updated] = await db
+ .update(rfqLastTbeSessions)
+ .set(updateData)
+ .where(eq(rfqLastTbeSessions.id, tbeSessionId))
+ .returning()
+
+ // 캐시 초기화
+ revalidateTag(`tbe-session-${tbeSessionId}`)
+ revalidateTag(`tbe-sessions`)
+
+ // RFQ 관련 캐시도 초기화
+ if (currentTbeSession.rfqsLastId) {
+ revalidateTag(`rfq-${currentTbeSession.rfqsLastId}`)
+ }
+
+ return {
+ success: true,
+ data: updated,
+ message: "평가가 성공적으로 저장되었습니다"
+ }
+
+ } catch (error) {
+ console.error("Failed to update TBE evaluation:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "평가 저장에 실패했습니다"
+ }
+ }
+}
+
+export async function getTbeVendorDocuments(tbeSessionId: number) {
+
+ try {
+ const documents = await db
+ .select({
+ id: rfqLastTbeVendorDocuments.id,
+ documentName: rfqLastTbeVendorDocuments.originalFileName,
+ documentType: rfqLastTbeVendorDocuments.documentType,
+ fileName: rfqLastTbeVendorDocuments.fileName,
+ fileSize: rfqLastTbeVendorDocuments.fileSize,
+ fileType: rfqLastTbeVendorDocuments.fileType,
+ documentNo: rfqLastTbeVendorDocuments.documentNo,
+ revisionNo: rfqLastTbeVendorDocuments.revisionNo,
+ issueDate: rfqLastTbeVendorDocuments.issueDate,
+ description: rfqLastTbeVendorDocuments.description,
+ submittedAt: rfqLastTbeVendorDocuments.submittedAt,
+ // 검토 정보는 rfqLastTbeDocumentReviews에서 가져옴
+ reviewStatus: rfqLastTbeDocumentReviews.reviewStatus,
+ reviewComments: rfqLastTbeDocumentReviews.reviewComments,
+ reviewedAt: rfqLastTbeDocumentReviews.reviewedAt,
+ requiresRevision: rfqLastTbeDocumentReviews.requiresRevision,
+ technicalCompliance: rfqLastTbeDocumentReviews.technicalCompliance,
+ qualityAcceptable: rfqLastTbeDocumentReviews.qualityAcceptable,
+ })
+ .from(rfqLastTbeVendorDocuments)
+ .leftJoin(
+ rfqLastTbeDocumentReviews,
+ and(
+ eq(rfqLastTbeDocumentReviews.vendorAttachmentId, rfqLastTbeVendorDocuments.id),
+ eq(rfqLastTbeDocumentReviews.documentSource, "vendor")
+ )
+ )
+ .where(eq(rfqLastTbeVendorDocuments.tbeSessionId, tbeSessionId))
+ .orderBy(rfqLastTbeVendorDocuments.submittedAt)
+
+ // 문서 정보 매핑 (reviewStatus는 이미 한글로 저장되어 있음)
+ const mappedDocuments = documents.map(doc => ({
+ ...doc,
+ reviewStatus: doc.reviewStatus || "미검토", // null인 경우 기본값
+ reviewRequired: doc.requiresRevision || false, // UI 호환성을 위해 필드명 매핑
+ }))
+
+ return {
+ success: true,
+ documents: mappedDocuments,
+ }
+ } catch (error) {
+ console.error("Failed to fetch vendor documents:", error)
+ return {
+ success: false,
+ error: "벤더 문서를 불러오는데 실패했습니다",
+ documents: [],
+ }
+ }
+}
+// 리뷰 상태 매핑 함수
+function mapReviewStatus(status: string | null): string {
+ const statusMap: Record<string, string> = {
+ "pending": "미검토",
+ "reviewing": "검토중",
+ "approved": "승인",
+ "rejected": "반려",
+ }
+
+ return status ? (statusMap[status] || status) : "미검토"
} \ No newline at end of file
diff --git a/lib/tbe-last/table/documents-sheet.tsx b/lib/tbe-last/table/documents-sheet.tsx
new file mode 100644
index 00000000..96e6e178
--- /dev/null
+++ b/lib/tbe-last/table/documents-sheet.tsx
@@ -0,0 +1,543 @@
+// lib/tbe-last/table/dialogs/documents-sheet.tsx
+
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription
+} from "@/components/ui/sheet"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { formatDate } from "@/lib/utils"
+import { downloadFile, getFileInfo } from "@/lib/file-download"
+import {
+ FileText,
+ Eye,
+ Download,
+ MoreHorizontal,
+ Filter,
+ MessageSquare,
+ CheckCircle,
+ XCircle,
+ Clock,
+ AlertCircle,
+ Save,
+} from "lucide-react"
+import { toast } from "sonner"
+import { useRouter } from "next/navigation"
+
+interface DocumentsSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ sessionDetail: any
+ isLoading: boolean
+}
+
+type CommentCount = { totalCount: number; openCount: number }
+type CountMap = Record<number, CommentCount>
+
+export function DocumentsSheet({
+ open,
+ onOpenChange,
+ sessionDetail,
+ isLoading
+}: DocumentsSheetProps) {
+
+ console.log(sessionDetail, "sessionDetail")
+
+ const [sourceFilter, setSourceFilter] = React.useState<"all" | "buyer" | "vendor">("all")
+ const [searchTerm, setSearchTerm] = React.useState("")
+ const [editingReviewId, setEditingReviewId] = React.useState<number | null>(null)
+ const [reviewData, setReviewData] = React.useState<Record<number, {
+ reviewStatus: string
+ reviewComments: string
+ }>>({})
+ const [isSaving, setIsSaving] = React.useState<Record<number, boolean>>({})
+ const [commentCounts, setCommentCounts] = React.useState<CountMap>({}) // <-- 추가
+ const [countLoading, setCountLoading] = React.useState(false)
+ const router = useRouter()
+
+ const allReviewIds = React.useMemo(() => {
+ const docs = sessionDetail?.documents ?? []
+ const ids = new Set<number>()
+ for (const d of docs) {
+ const id = Number(d?.documentReviewId)
+ if (Number.isFinite(id)) ids.add(id)
+ }
+ return Array.from(ids)
+ }, [sessionDetail?.documents])
+
+ React.useEffect(() => {
+ let aborted = false
+ ; (async () => {
+ if (allReviewIds.length === 0) {
+ setCommentCounts({})
+ return
+ }
+ setCountLoading(true)
+ try {
+ // 너무 길어질 수 있으니 적당히 나눠서 호출(옵션)
+ const chunkSize = 100
+ const chunks: number[][] = []
+ for (let i = 0; i < allReviewIds.length; i += chunkSize) {
+ chunks.push(allReviewIds.slice(i, i + chunkSize))
+ }
+
+ const merged: CountMap = {}
+ for (const c of chunks) {
+ const qs = encodeURIComponent(c.join(","))
+ const res = await fetch(`/api/pdftron-comments/xfdf/count?ids=${qs}`, {
+ credentials: "include",
+ cache: "no-store",
+ })
+ if (!res.ok) throw new Error(`count api ${res.status}`)
+ const json = await res.json()
+ if (aborted) return
+ const data = (json?.data ?? {}) as Record<string, { totalCount: number; openCount: number }>
+ for (const [k, v] of Object.entries(data)) {
+ const idNum = Number(k)
+ if (Number.isFinite(idNum)) {
+ merged[idNum] = { totalCount: v.totalCount ?? 0, openCount: v.openCount ?? 0 }
+ }
+ }
+ }
+ if (!aborted) setCommentCounts(merged)
+ } catch (e) {
+ console.error("Failed to load comment counts", e)
+ } finally {
+ if (!aborted) setCountLoading(false)
+ }
+ })()
+ return () => {
+ aborted = true
+ }
+ }, [allReviewIds.join(",")]) // 의존성: id 목록이 바뀔 때만
+
+ // 문서 초기 데이터 설정
+ React.useEffect(() => {
+ if (sessionDetail?.documents) {
+ const initialData: Record<number, any> = {}
+ sessionDetail.documents.forEach((doc: any) => {
+ initialData[doc.documentReviewId] = {
+ reviewStatus: doc.reviewStatus || "미검토",
+ reviewComments: doc.reviewComments || ""
+ }
+ })
+ setReviewData(initialData)
+ }
+ }, [sessionDetail])
+
+ // PDFtron 뷰어 열기
+ const handleOpenPDFTron = (doc: any) => {
+ if (!doc.filePath) {
+ toast.error("파일 경로를 찾을 수 없습니다")
+ return
+ }
+
+ const params = new URLSearchParams({
+ filePath: doc.filePath,
+ documentId: doc.documentId.toString(),
+ documentReviewId: doc.documentReviewId?.toString() || '',
+ sessionId: sessionDetail?.session?.tbeSessionId?.toString() || '',
+ documentName: doc.documentName || '',
+ mode: 'review'
+ })
+
+ window.open(`/pdftron-viewer?${params.toString()}`, '_blank')
+ }
+
+ // 파일이 PDFtron에서 열 수 있는지 확인
+ const canOpenInPDFTron = (filePath: string) => {
+ if (!filePath) return false
+ const ext = filePath.split('.').pop()?.toLowerCase()
+ const supportedFormats = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'tiff', 'bmp']
+ return supportedFormats.includes(ext || '')
+ }
+
+ // 파일 다운로드
+ const handleDownload = async (doc: any) => {
+ if (!doc.filePath) {
+ toast.error("파일 경로를 찾을 수 없습니다")
+ return
+ }
+
+ await downloadFile(doc.filePath, doc.originalFileName || doc.documentName, {
+ action: 'download',
+ showToast: true,
+ onError: (error) => {
+ console.error('Download error:', error)
+ }
+ })
+ }
+
+ // 리뷰 상태 저장
+ const handleSaveReview = async (doc: any) => {
+ const reviewId = doc.documentReviewId
+ setIsSaving({ ...isSaving, [reviewId]: true })
+
+ try {
+ // API 호출하여 리뷰 상태 저장
+ const response = await fetch(`/api/document-reviews/${reviewId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ reviewStatus: reviewData[reviewId]?.reviewStatus,
+ reviewComments: reviewData[reviewId]?.reviewComments
+ })
+ })
+
+ if (!response.ok) throw new Error('Failed to save review')
+
+ toast.success("리뷰 저장 완료")
+ router.refresh()
+ setEditingReviewId(null)
+ } catch (error) {
+ console.error('Save review error:', error)
+ toast.error("리뷰 저장 실패")
+ } finally {
+ setIsSaving({ ...isSaving, [reviewId]: false })
+ }
+ }
+
+ // 리뷰 상태 아이콘
+ const getReviewStatusIcon = (status: string) => {
+ switch (status) {
+ case "승인":
+ return <CheckCircle className="h-4 w-4 text-green-600" />
+ case "반려":
+ return <XCircle className="h-4 w-4 text-red-600" />
+ case "보류":
+ return <AlertCircle className="h-4 w-4 text-yellow-600" />
+ default:
+ return <Clock className="h-4 w-4 text-gray-400" />
+ }
+ }
+
+ // 필터링된 문서 목록
+ const filteredDocuments = React.useMemo(() => {
+ if (!sessionDetail?.documents) return []
+
+ return sessionDetail.documents.filter((doc: any) => {
+ // Source 필터
+ if (sourceFilter !== "all" && doc.documentSource !== sourceFilter) {
+ return false
+ }
+
+ // 검색어 필터
+ if (searchTerm) {
+ const searchLower = searchTerm.toLowerCase()
+ return (
+ doc.documentName?.toLowerCase().includes(searchLower) ||
+ doc.documentType?.toLowerCase().includes(searchLower) ||
+ doc.reviewComments?.toLowerCase().includes(searchLower)
+ )
+ }
+
+ return true
+ })
+ }, [sessionDetail?.documents, sourceFilter, searchTerm])
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[1200px] sm:w-[1200px] max-w-[90vw]" style={{ width: 1200, maxWidth: "90vw" }}>
+ <SheetHeader>
+ <SheetTitle>Documents & Review Management</SheetTitle>
+ <SheetDescription>
+ 문서 검토 및 코멘트 관리
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* 필터 및 검색 */}
+ <div className="flex items-center gap-4 mt-4 mb-4">
+ <div className="flex items-center gap-2">
+ <Filter className="h-4 w-4 text-muted-foreground" />
+ <Select value={sourceFilter} onValueChange={(value: any) => setSourceFilter(value)}>
+ <SelectTrigger className="w-[150px]">
+ <SelectValue placeholder="Filter by source" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All Documents</SelectItem>
+ <SelectItem value="buyer">Buyer Documents</SelectItem>
+ <SelectItem value="vendor">Vendor Documents</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Input
+ placeholder="Search documents..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="max-w-sm"
+ />
+
+ <div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground">
+ <Badge variant="outline">
+ Total: {filteredDocuments.length}
+ </Badge>
+ {sessionDetail?.documents && (
+ <>
+ <Badge variant="secondary">
+ Buyer: {sessionDetail.documents.filter((d: any) => d.documentSource === "buyer").length}
+ </Badge>
+ <Badge variant="secondary">
+ Vendor: {sessionDetail.documents.filter((d: any) => d.documentSource === "vendor").length}
+ </Badge>
+ </>
+ )}
+ </div>
+ </div>
+
+ {/* 문서 테이블 */}
+ {isLoading ? (
+ <div className="p-8 text-center">Loading...</div>
+ ) : (
+ <ScrollArea className="h-[calc(100vh-250px)]">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[100px]">Source</TableHead>
+ <TableHead>Document Name</TableHead>
+ <TableHead className="w-[100px]">Type</TableHead>
+ <TableHead className="w-[120px]">Review Status</TableHead>
+ <TableHead className="w-[120px]">Comments</TableHead>
+ <TableHead className="w-[200px]">Review Notes</TableHead>
+ <TableHead className="w-[120px]">Uploaded</TableHead>
+ <TableHead className="w-[100px] text-right">Actions</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {filteredDocuments.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={8} className="text-center text-muted-foreground">
+ No documents found
+ </TableCell>
+ </TableRow>
+ ) : (
+ filteredDocuments.map((doc: any) => (
+ <TableRow key={doc.documentReviewId}>
+ <TableCell>
+ <Badge variant={doc.documentSource === "buyer" ? "default" : "secondary"}>
+ {doc.documentSource}
+ </Badge>
+ </TableCell>
+
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium">{doc.documentName}</span>
+ </div>
+ </TableCell>
+
+ <TableCell>{doc.documentType}</TableCell>
+
+ <TableCell>
+ {editingReviewId === doc.documentReviewId ? (
+ <Select
+ value={reviewData[doc.documentReviewId]?.reviewStatus || "미검토"}
+ onValueChange={(value) => {
+ setReviewData({
+ ...reviewData,
+ [doc.documentReviewId]: {
+ ...reviewData[doc.documentReviewId],
+ reviewStatus: value
+ }
+ })
+ }}
+ >
+ <SelectTrigger className="w-[110px] h-8">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="미검토">미검토</SelectItem>
+ <SelectItem value="검토중">검토중</SelectItem>
+ <SelectItem value="승인">승인</SelectItem>
+ <SelectItem value="반려">반려</SelectItem>
+ <SelectItem value="보류">보류</SelectItem>
+ </SelectContent>
+ </Select>
+ ) : (
+ <div className="flex items-center gap-1">
+ {getReviewStatusIcon(reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus)}
+ <span className="text-sm">
+ {reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus || "미검토"}
+ </span>
+ </div>
+ )}
+ </TableCell>
+
+
+ <TableCell>
+ {(() => {
+ const id = Number(doc.documentReviewId)
+ const counts = Number.isFinite(id) ? commentCounts[id] : undefined
+ if (countLoading && !counts) {
+ return <span className="text-xs text-muted-foreground">Loading…</span>
+ }
+ if (!counts || counts.totalCount === 0) {
+ return <span className="text-muted-foreground text-xs">-</span>
+ }
+ return (
+ <div className="flex items-center gap-1">
+ <MessageSquare className="h-3 w-3" />
+ <span className="text-xs">
+ {counts.totalCount}
+ {counts.openCount > 0 && (
+ <span className="text-orange-600 ml-1">
+ ({counts.openCount} open)
+ </span>
+ )}
+ </span>
+ </div>
+ )
+ })()}
+ </TableCell>
+
+ <TableCell>
+ {editingReviewId === doc.documentReviewId ? (
+ <Textarea
+ value={reviewData[doc.documentReviewId]?.reviewComments || ""}
+ onChange={(e) => {
+ setReviewData({
+ ...reviewData,
+ [doc.documentReviewId]: {
+ ...reviewData[doc.documentReviewId],
+ reviewComments: e.target.value
+ }
+ })
+ }}
+ placeholder="리뷰 코멘트 입력..."
+ className="min-h-[60px] text-xs"
+ />
+ ) : (
+ <p className="text-xs text-muted-foreground truncate max-w-[200px]"
+ title={reviewData[doc.documentReviewId]?.reviewComments || doc.reviewComments}>
+ {reviewData[doc.documentReviewId]?.reviewComments || doc.reviewComments || "-"}
+ </p>
+ )}
+ </TableCell>
+
+ <TableCell>
+ <span className="text-xs text-muted-foreground">
+ {doc.uploadedAt ? formatDate(doc.uploadedAt, "KR") :
+ doc.submittedAt ? formatDate(doc.submittedAt, "KR") : "-"}
+ </span>
+ </TableCell>
+
+ <TableCell className="text-right">
+ <div className="flex items-center justify-end gap-1">
+ {canOpenInPDFTron(doc.filePath) ? (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => handleOpenPDFTron(doc)}
+ className="h-8 px-2"
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ ) : null}
+
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => handleDownload(doc)}
+ className="h-8 px-2"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="sm" className="h-8 px-2">
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {editingReviewId === doc.documentReviewId ? (
+ <>
+ <DropdownMenuItem
+ onClick={() => handleSaveReview(doc)}
+ disabled={isSaving[doc.documentReviewId]}
+ >
+ <Save className="h-4 w-4 mr-2" />
+ {isSaving[doc.documentReviewId] ? "저장 중..." : "저장"}
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => {
+ setEditingReviewId(null)
+ // 원래 값으로 복원
+ setReviewData({
+ ...reviewData,
+ [doc.documentReviewId]: {
+ reviewStatus: doc.reviewStatus || "미검토",
+ reviewComments: doc.reviewComments || ""
+ }
+ })
+ }}
+ >
+ <XCircle className="h-4 w-4 mr-2" />
+ 취소
+ </DropdownMenuItem>
+ </>
+ ) : (
+ <DropdownMenuItem
+ onClick={() => setEditingReviewId(doc.documentReviewId)}
+ >
+ <MessageSquare className="h-4 w-4 mr-2" />
+ 리뷰 편집
+ </DropdownMenuItem>
+ )}
+
+ {canOpenInPDFTron(doc.filePath) && (
+ <DropdownMenuItem onClick={() => handleOpenPDFTron(doc)}>
+ <Eye className="h-4 w-4 mr-2" />
+ PDFTron에서 보기
+ </DropdownMenuItem>
+ )}
+
+ <DropdownMenuItem onClick={() => handleDownload(doc)}>
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </TableCell>
+ </TableRow>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+ )}
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tbe-last/table/evaluation-dialog.tsx b/lib/tbe-last/table/evaluation-dialog.tsx
new file mode 100644
index 00000000..ac1d923b
--- /dev/null
+++ b/lib/tbe-last/table/evaluation-dialog.tsx
@@ -0,0 +1,432 @@
+// lib/tbe-last/table/dialogs/evaluation-dialog.tsx
+
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Textarea } from "@/components/ui/textarea"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { TbeLastView } from "@/db/schema"
+import { toast } from "sonner"
+import { updateTbeEvaluation ,getTbeVendorDocuments} from "../service"
+import {
+ FileText,
+ CheckCircle,
+ XCircle,
+ AlertCircle,
+ Clock,
+ Loader2,
+ Info
+} from "lucide-react"
+
+// 폼 스키마
+const evaluationSchema = z.object({
+ evaluationResult: z.enum(["Acceptable", "Acceptable with Comment", "Not Acceptable"], {
+ required_error: "평가 결과를 선택해주세요",
+ }),
+ conditionalRequirements: z.string().optional(),
+ conditionsFulfilled: z.boolean().default(false),
+ overallRemarks: z.string().optional(),
+})
+
+type EvaluationFormValues = z.infer<typeof evaluationSchema>
+
+interface EvaluationDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedSession: TbeLastView | null
+ onSuccess?: () => void
+}
+
+export function EvaluationDialog({
+ open,
+ onOpenChange,
+ selectedSession,
+ onSuccess
+}: EvaluationDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isLoadingDocs, setIsLoadingDocs] = React.useState(false)
+ const [vendorDocuments, setVendorDocuments] = React.useState<any[]>([])
+
+ const form = useForm<EvaluationFormValues>({
+ resolver: zodResolver(evaluationSchema),
+ defaultValues: {
+ evaluationResult: undefined,
+ conditionalRequirements: "",
+ conditionsFulfilled: false,
+ overallRemarks: "",
+ },
+ })
+
+ const watchEvaluationResult = form.watch("evaluationResult")
+ const isFormValid = form.formState.isValid
+
+ // 벤더 문서 리뷰 상태 가져오기
+ React.useEffect(() => {
+ if (open && selectedSession?.tbeSessionId) {
+ fetchVendorDocuments()
+
+ // 기존 평가 데이터가 있으면 폼에 설정
+ if (selectedSession.evaluationResult) {
+ form.reset({
+ evaluationResult: selectedSession.evaluationResult as any,
+ conditionalRequirements: selectedSession.conditionalRequirements || "",
+ conditionsFulfilled: selectedSession.conditionsFulfilled || false,
+ overallRemarks: selectedSession.overallRemarks || "",
+ })
+ } else {
+ // 기존 평가 데이터가 없으면 초기화
+ form.reset({
+ evaluationResult: undefined,
+ conditionalRequirements: "",
+ conditionsFulfilled: false,
+ overallRemarks: "",
+ })
+ }
+ } else if (!open) {
+ // 다이얼로그가 닫힐 때 폼 리셋
+ form.reset({
+ evaluationResult: undefined,
+ conditionalRequirements: "",
+ conditionsFulfilled: false,
+ overallRemarks: "",
+ })
+ setVendorDocuments([])
+ }
+ }, [open, selectedSession])
+
+ const fetchVendorDocuments = async () => {
+ if (!selectedSession?.tbeSessionId) return
+
+ setIsLoadingDocs(true)
+ try {
+ // 서버 액션 호출
+ const result = await getTbeVendorDocuments(selectedSession.tbeSessionId)
+
+ if (result.success) {
+ setVendorDocuments(result.documents || [])
+ } else {
+ console.error("Failed to fetch vendor documents:", result.error)
+ toast.error(result.error || "벤더 문서 정보를 불러오는데 실패했습니다")
+ }
+ } catch (error) {
+ console.error("Failed to fetch vendor documents:", error)
+ toast.error("벤더 문서 정보를 불러오는데 실패했습니다")
+ } finally {
+ setIsLoadingDocs(false)
+ }
+ }
+
+ const getReviewStatusIcon = (status: string) => {
+ switch (status) {
+ case "승인":
+ return <CheckCircle className="h-4 w-4 text-green-600" />
+ case "반려":
+ return <XCircle className="h-4 w-4 text-red-600" />
+ case "재검토필요":
+ return <AlertCircle className="h-4 w-4 text-yellow-600" />
+ case "검토완료":
+ return <CheckCircle className="h-4 w-4 text-blue-600" />
+ case "검토중":
+ return <Clock className="h-4 w-4 text-orange-600" />
+ default:
+ return <Clock className="h-4 w-4 text-gray-400" />
+ }
+ }
+
+ const getReviewStatusVariant = (status: string): any => {
+ switch (status) {
+ case "승인":
+ return "default"
+ case "반려":
+ return "destructive"
+ case "재검토필요":
+ return "secondary"
+ case "검토완료":
+ return "outline"
+ default:
+ return "outline"
+ }
+ }
+
+ const onSubmit = async (values: EvaluationFormValues) => {
+ if (!selectedSession?.tbeSessionId) return
+
+ // 벤더 문서가 없는 경우 경고
+ if (vendorDocuments.length === 0 && !isLoadingDocs) {
+ const confirmed = window.confirm(
+ "검토된 벤더 문서가 없습니다. 그래도 평가를 진행하시겠습니까?"
+ )
+ if (!confirmed) return
+ }
+
+ setIsLoading(true)
+ try {
+ // 서버 액션 호출
+ const result = await updateTbeEvaluation(selectedSession.tbeSessionId, {
+ ...values,
+ status: "완료", // 평가 완료 시 상태 업데이트
+ })
+
+ if (result.success) {
+ toast.success("평가가 성공적으로 저장되었습니다")
+ form.reset()
+ onOpenChange(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "평가 저장에 실패했습니다")
+ }
+ } catch (error) {
+ console.error("Failed to save evaluation:", error)
+ toast.error("평가 저장 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const allDocumentsApproved = vendorDocuments.length > 0 &&
+ vendorDocuments.every((doc: any) => doc.reviewStatus === "승인" || doc.reviewStatus === "검토완료")
+
+ const hasRejectedDocuments = vendorDocuments.some((doc: any) => doc.reviewStatus === "반려")
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-3xl max-h-[90vh]">
+ <DialogHeader>
+ <DialogTitle>TBE 결과 입력</DialogTitle>
+ <DialogDescription>
+ {selectedSession?.sessionCode} - {selectedSession?.vendorName}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="overflow-y-auto max-h-[calc(90vh-200px)] pr-4">
+ <div className="space-y-6">
+ {/* 벤더 문서 검토 현황 */}
+ <div className="space-y-3">
+ <h3 className="text-sm font-semibold">벤더 문서 검토 현황</h3>
+
+ {isLoadingDocs ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ <span className="text-sm text-muted-foreground">문서 정보 로딩 중...</span>
+ </div>
+ ) : vendorDocuments.length === 0 ? (
+ <Alert>
+ <Info className="h-4 w-4" />
+ <AlertDescription>
+ 검토할 벤더 문서가 없습니다.
+ </AlertDescription>
+ </Alert>
+ ) : (
+ <div className="space-y-2">
+ {vendorDocuments.map((doc: any) => (
+ <div key={doc.id} className="flex items-center justify-between p-3 border rounded-lg">
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{doc.documentName}</p>
+ <p className="text-xs text-muted-foreground">{doc.documentType}</p>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {getReviewStatusIcon(doc.reviewStatus)}
+ <Badge variant={getReviewStatusVariant(doc.reviewStatus)}>
+ {doc.reviewStatus}
+ </Badge>
+ </div>
+ </div>
+ ))}
+
+ {/* 문서 검토 상태 요약 */}
+ <div className="mt-3 p-3 bg-muted rounded-lg">
+ <div className="flex items-center justify-between text-sm">
+ <span>전체 문서: {vendorDocuments.length}개</span>
+ <div className="flex items-center gap-4">
+ <span className="text-green-600">
+ 승인: {vendorDocuments.filter(d => d.reviewStatus === "승인").length}
+ </span>
+ <span className="text-red-600">
+ 반려: {vendorDocuments.filter(d => d.reviewStatus === "반려").length}
+ </span>
+ <span className="text-gray-600">
+ 미검토: {vendorDocuments.filter(d => d.reviewStatus === "미검토").length}
+ </span>
+ </div>
+ </div>
+
+ {hasRejectedDocuments && (
+ <Alert className="mt-2" variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 반려된 문서가 있습니다. 평가 결과를 "Not Acceptable"로 설정하는 것을 권장합니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {!allDocumentsApproved && !hasRejectedDocuments && (
+ <Alert className="mt-2">
+ <Info className="h-4 w-4" />
+ <AlertDescription>
+ 모든 문서 검토가 완료되지 않았습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 평가 폼 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="evaluationResult"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 평가 결과 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="평가 결과를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="Acceptable">Acceptable</SelectItem>
+ <SelectItem value="Acceptable with Comment">Acceptable with Comment</SelectItem>
+ <SelectItem value="Not Acceptable">Not Acceptable</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ 최종 평가 결과를 선택합니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 조건부 승인 필드 */}
+ {watchEvaluationResult === "Acceptable with Comment" && (
+ <>
+ <FormField
+ control={form.control}
+ name="conditionalRequirements"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>조건부 요구사항</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="조건부 승인에 필요한 요구사항을 입력하세요..."
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 벤더가 충족해야 할 조건을 명확히 기술합니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="conditionsFulfilled"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>
+ 조건 충족 확인
+ </FormLabel>
+ <FormDescription>
+ 벤더가 요구 조건을 모두 충족했는지 확인합니다.
+ </FormDescription>
+ </div>
+ </FormItem>
+ )}
+ />
+ </>
+ )}
+
+ {/* 평가 요약 - 종합 의견만 */}
+ <FormField
+ control={form.control}
+ name="overallRemarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>종합 의견</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="종합적인 평가 의견을 입력하세요..."
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isLoading || !isFormValid}
+ >
+ {isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
+ 평가 저장
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tbe-last/table/pr-items-dialog.tsx b/lib/tbe-last/table/pr-items-dialog.tsx
new file mode 100644
index 00000000..780d4b5b
--- /dev/null
+++ b/lib/tbe-last/table/pr-items-dialog.tsx
@@ -0,0 +1,83 @@
+// lib/tbe-last/table/dialogs/pr-items-dialog.tsx
+
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { formatDate } from "@/lib/utils"
+
+interface PrItemsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ sessionDetail: any
+ isLoading: boolean
+}
+
+export function PrItemsDialog({
+ open,
+ onOpenChange,
+ sessionDetail,
+ isLoading
+}: PrItemsDialogProps) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>PR Items</DialogTitle>
+ <DialogDescription>
+ Purchase Request items for this RFQ
+ </DialogDescription>
+ </DialogHeader>
+ {isLoading ? (
+ <div className="p-8 text-center">Loading...</div>
+ ) : sessionDetail?.prItems ? (
+ <div className="border rounded-lg">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b bg-muted/50">
+ <th className="text-left p-2">PR No</th>
+ <th className="text-left p-2">Material Code</th>
+ <th className="text-left p-2">Description</th>
+ <th className="text-left p-2">Size</th>
+ <th className="text-left p-2">Qty</th>
+ <th className="text-left p-2">Unit</th>
+ <th className="text-left p-2">Delivery</th>
+ <th className="text-left p-2">Major</th>
+ </tr>
+ </thead>
+ <tbody>
+ {sessionDetail.prItems.map((item: any) => (
+ <tr key={item.id} className="border-b hover:bg-muted/20">
+ <td className="p-2">{item.prNo}</td>
+ <td className="p-2">{item.materialCode}</td>
+ <td className="p-2">{item.materialDescription}</td>
+ <td className="p-2">{item.size || "-"}</td>
+ <td className="p-2 text-right">{item.quantity}</td>
+ <td className="p-2">{item.uom}</td>
+ <td className="p-2">
+ {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"}
+ </td>
+ <td className="p-2 text-center">
+ {item.majorYn && <Badge variant="default">Major</Badge>}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ ) : (
+ <div className="p-8 text-center text-muted-foreground">
+ No PR items available
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tbe-last/table/session-detail-dialog.tsx b/lib/tbe-last/table/session-detail-dialog.tsx
new file mode 100644
index 00000000..ae5add41
--- /dev/null
+++ b/lib/tbe-last/table/session-detail-dialog.tsx
@@ -0,0 +1,103 @@
+// lib/tbe-last/table/dialogs/session-detail-dialog.tsx
+
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { formatDate } from "@/lib/utils"
+
+interface SessionDetailDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ sessionDetail: any
+ isLoading: boolean
+}
+
+export function SessionDetailDialog({
+ open,
+ onOpenChange,
+ sessionDetail,
+ isLoading
+}: SessionDetailDialogProps) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>TBE Session Detail</DialogTitle>
+ <DialogDescription>
+ {sessionDetail?.session?.sessionCode} - {sessionDetail?.session?.vendorName}
+ </DialogDescription>
+ </DialogHeader>
+ {isLoading ? (
+ <div className="p-8 text-center">Loading...</div>
+ ) : sessionDetail ? (
+ <div className="space-y-4">
+ {/* Session info */}
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <p className="text-sm font-medium">RFQ Code</p>
+ <p className="text-sm text-muted-foreground">{sessionDetail.session.rfqCode}</p>
+ </div>
+ <div>
+ <p className="text-sm font-medium">Status</p>
+ <Badge>{sessionDetail.session.sessionStatus}</Badge>
+ </div>
+ <div>
+ <p className="text-sm font-medium">Project</p>
+ <p className="text-sm text-muted-foreground">
+ {sessionDetail.session.projectCode} - {sessionDetail.session.projectName}
+ </p>
+ </div>
+ <div>
+ <p className="text-sm font-medium">Package</p>
+ <p className="text-sm text-muted-foreground">
+ {sessionDetail.session.packageNo} - {sessionDetail.session.packageName}
+ </p>
+ </div>
+ </div>
+
+ {/* PR Items */}
+ {sessionDetail.prItems?.length > 0 && (
+ <div>
+ <h3 className="font-medium mb-2">PR Items</h3>
+ <div className="border rounded-lg">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b">
+ <th className="text-left p-2">PR No</th>
+ <th className="text-left p-2">Material Code</th>
+ <th className="text-left p-2">Description</th>
+ <th className="text-left p-2">Qty</th>
+ <th className="text-left p-2">Delivery</th>
+ </tr>
+ </thead>
+ <tbody>
+ {sessionDetail.prItems.map((item: any) => (
+ <tr key={item.id} className="border-b">
+ <td className="p-2">{item.prNo}</td>
+ <td className="p-2">{item.materialCode}</td>
+ <td className="p-2">{item.materialDescription}</td>
+ <td className="p-2">{item.quantity} {item.uom}</td>
+ <td className="p-2">
+ {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ )}
+ </div>
+ ) : null}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tbe-last/table/tbe-last-table-columns.tsx b/lib/tbe-last/table/tbe-last-table-columns.tsx
index 71b3acde..726d8925 100644
--- a/lib/tbe-last/table/tbe-last-table-columns.tsx
+++ b/lib/tbe-last/table/tbe-last-table-columns.tsx
@@ -4,7 +4,7 @@
import * as React from "react"
import { type ColumnDef } from "@tanstack/react-table"
-import { FileText, MessageSquare, Package, ListChecks } from "lucide-react"
+import { FileText, Package, ListChecks } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@@ -77,72 +77,64 @@ export function getColumns({
size: 120,
},
- // RFQ Info Group
+ // RFQ Code
{
- id: "rfqInfo",
- header: "RFQ Information",
- columns: [
- {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ Code" />
- ),
- cell: ({ row }) => row.original.rfqCode,
- size: 120,
- },
- {
- accessorKey: "rfqTitle",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ Title" />
- ),
- cell: ({ row }) => row.original.rfqTitle || "-",
- size: 200,
- },
- {
- accessorKey: "rfqDueDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Due Date" />
- ),
- cell: ({ row }) => {
- const date = row.original.rfqDueDate;
- return date ? formatDate(date, "KR") : "-";
- },
- size: 100,
- },
- ],
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Code" />
+ ),
+ cell: ({ row }) => row.original.rfqCode,
+ size: 120,
},
- // Package Info
+ // RFQ Title
{
- id: "packageInfo",
- header: "Package",
- columns: [
- {
- accessorKey: "packageNo",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Package No" />
- ),
- cell: ({ row }) => {
- const packageNo = row.original.packageNo;
- const packageName = row.original.packageName;
-
- if (!packageNo) return "-";
-
- return (
- <div className="flex flex-col">
- <span className="font-medium">{packageNo}</span>
- {packageName && (
- <span className="text-xs text-muted-foreground">{packageName}</span>
- )}
- </div>
- );
- },
- size: 150,
- },
- ],
+ accessorKey: "rfqTitle",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Title" />
+ ),
+ cell: ({ row }) => row.original.rfqTitle || "-",
+ size: 200,
+ },
+
+ // RFQ Due Date
+ {
+ accessorKey: "rfqDueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Due Date" />
+ ),
+ cell: ({ row }) => {
+ const date = row.original.rfqDueDate;
+ return date ? formatDate(date, "KR") : "-";
+ },
+ size: 100,
+ },
+
+ // Package No
+ {
+ accessorKey: "packageNo",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Package No" />
+ ),
+ cell: ({ row }) => {
+ const packageNo = row.original.packageNo;
+ const packageName = row.original.packageName;
+
+ if (!packageNo) return "-";
+
+ return (
+ <div className="flex flex-col">
+ <span className="font-medium">{packageNo}</span>
+ {packageName && (
+ <span className="text-xs text-muted-foreground">{packageName}</span>
+ )}
+ </div>
+ );
+ },
+ size: 150,
},
- // Project Info
+ // Project
{
accessorKey: "projectCode",
header: ({ column }) => (
@@ -166,28 +158,44 @@ export function getColumns({
size: 150,
},
- // Vendor Info
+ // Vendor Code
{
- id: "vendorInfo",
- header: "Vendor",
- columns: [
- {
- accessorKey: "vendorCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Vendor Code" />
- ),
- cell: ({ row }) => row.original.vendorCode || "-",
- size: 100,
- },
- {
- accessorKey: "vendorName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Vendor Name" />
- ),
- cell: ({ row }) => row.original.vendorName,
- size: 200,
- },
- ],
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Vendor Code" />
+ ),
+ cell: ({ row }) => row.original.vendorCode || "-",
+ size: 100,
+ },
+
+ // Vendor Name
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Vendor Name" />
+ ),
+ cell: ({ row }) => row.original.vendorName,
+ size: 200,
+ },
+
+ // 구매담당자 (PIC Name)
+ {
+ accessorKey: "picName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="구매담당자" />
+ ),
+ cell: ({ row }) => row.original.picName || "-",
+ size: 120,
+ },
+
+ // 설계담당자 (Engineering PIC Name)
+ {
+ accessorKey: "EngPicName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설계담당자" />
+ ),
+ cell: ({ row }) => row.original.EngPicName || "-",
+ size: 120,
},
// TBE Status
@@ -239,7 +247,7 @@ export function getColumns({
<Button
variant="outline"
size="sm"
- onClick={() => onOpenEvaluation(session)}
+ onClick={() => onOpenEvaluation(session )}
>
평가입력
</Button>
@@ -314,9 +322,9 @@ export function getColumns({
),
cell: ({ row }) => {
const sessionId = row.original.tbeSessionId;
- const buyerDocs = row.original.buyerDocumentsCount;
- const vendorDocs = row.original.vendorDocumentsCount;
- const reviewedDocs = row.original.reviewedDocumentsCount;
+ const buyerDocs = Number(row.original.buyerDocumentsCount);
+ const vendorDocs = Number(row.original.vendorDocumentsCount);
+ const reviewedDocs = Number(row.original.reviewedDocumentsCount);
const totalDocs = buyerDocs + vendorDocs;
return (
@@ -336,40 +344,6 @@ export function getColumns({
size: 100,
enableSorting: false,
},
-
- // Comments
- {
- id: "comments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Comments" />
- ),
- cell: ({ row }) => {
- const sessionId = row.original.tbeSessionId;
- const totalComments = row.original.totalCommentsCount;
- const unresolvedComments = row.original.unresolvedCommentsCount;
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="h-8 px-2 relative"
- onClick={() => onOpenDocuments(sessionId)}
- >
- <MessageSquare className="h-4 w-4" />
- {totalComments > 0 && (
- <Badge
- variant={unresolvedComments > 0 ? "destructive" : "secondary"}
- className="absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem]"
- >
- {unresolvedComments > 0 ? unresolvedComments : totalComments}
- </Badge>
- )}
- </Button>
- );
- },
- size: 80,
- enableSorting: false,
- },
];
return columns;
diff --git a/lib/tbe-last/table/tbe-last-table.tsx b/lib/tbe-last/table/tbe-last-table.tsx
index 64707e4e..a9328bdf 100644
--- a/lib/tbe-last/table/tbe-last-table.tsx
+++ b/lib/tbe-last/table/tbe-last-table.tsx
@@ -4,7 +4,7 @@
import * as React from "react"
import { useRouter } from "next/navigation"
-import { type DataTableFilterField } from "@/types/table"
+import { type DataTableAdvancedFilterField } from "@/types/table"
import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
@@ -14,24 +14,12 @@ import { getAllTBELast, getTBESessionDetail } from "@/lib/tbe-last/service"
import { Button } from "@/components/ui/button"
import { Download, RefreshCw } from "lucide-react"
import { exportTableToExcel } from "@/lib/export"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription
-} from "@/components/ui/dialog"
-import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
- SheetDescription
-} from "@/components/ui/sheet"
-import { Badge } from "@/components/ui/badge"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { formatDate } from "@/lib/utils"
+
+// Import Dialogs and Sheets
+import { SessionDetailDialog } from "./session-detail-dialog"
+import { DocumentsSheet } from "./documents-sheet"
+import { PrItemsDialog } from "./pr-items-dialog"
+import { EvaluationDialog } from "./evaluation-dialog"
interface TbeLastTableProps {
promises: Promise<[
@@ -43,6 +31,8 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
const router = useRouter()
const [{ data, pageCount }] = React.use(promises)
+ console.log(data,"data")
+
// Dialog states
const [sessionDetailOpen, setSessionDetailOpen] = React.useState(false)
const [documentsOpen, setDocumentsOpen] = React.useState(false)
@@ -90,6 +80,8 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
const handleOpenEvaluation = React.useCallback((session: TbeLastView) => {
setSelectedSession(session)
setEvaluationOpen(true)
+ loadSessionDetail(session.rfqId)
+
}, [])
const handleRefresh = React.useCallback(() => {
@@ -109,7 +101,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
)
// Filter fields
- const filterFields: DataTableFilterField<TbeLastView>[] = [
+ const filterFields: DataTableAdvancedFilterField<TbeLastView>[] = [
{
id: "sessionStatus",
label: "Status",
@@ -144,7 +136,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
enableAdvancedFilter: true,
initialState: {
sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["documents", "comments"] },
+ columnPinning: { right: ["documents"] },
},
getRowId: (originalRow) => String(originalRow.tbeSessionId),
shallow: false,
@@ -188,232 +180,37 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
</DataTable>
{/* Session Detail Dialog */}
- <Dialog open={sessionDetailOpen} onOpenChange={setSessionDetailOpen}>
- <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle>TBE Session Detail</DialogTitle>
- <DialogDescription>
- {sessionDetail?.session?.sessionCode} - {sessionDetail?.session?.vendorName}
- </DialogDescription>
- </DialogHeader>
- {isLoadingDetail ? (
- <div className="p-8 text-center">Loading...</div>
- ) : sessionDetail ? (
- <div className="space-y-4">
- {/* Session info */}
- <div className="grid grid-cols-2 gap-4">
- <div>
- <p className="text-sm font-medium">RFQ Code</p>
- <p className="text-sm text-muted-foreground">{sessionDetail.session.rfqCode}</p>
- </div>
- <div>
- <p className="text-sm font-medium">Status</p>
- <Badge>{sessionDetail.session.sessionStatus}</Badge>
- </div>
- <div>
- <p className="text-sm font-medium">Project</p>
- <p className="text-sm text-muted-foreground">
- {sessionDetail.session.projectCode} - {sessionDetail.session.projectName}
- </p>
- </div>
- <div>
- <p className="text-sm font-medium">Package</p>
- <p className="text-sm text-muted-foreground">
- {sessionDetail.session.packageNo} - {sessionDetail.session.packageName}
- </p>
- </div>
- </div>
-
- {/* PR Items */}
- {sessionDetail.prItems?.length > 0 && (
- <div>
- <h3 className="font-medium mb-2">PR Items</h3>
- <div className="border rounded-lg">
- <table className="w-full text-sm">
- <thead>
- <tr className="border-b">
- <th className="text-left p-2">PR No</th>
- <th className="text-left p-2">Material Code</th>
- <th className="text-left p-2">Description</th>
- <th className="text-left p-2">Qty</th>
- <th className="text-left p-2">Delivery</th>
- </tr>
- </thead>
- <tbody>
- {sessionDetail.prItems.map((item: any) => (
- <tr key={item.id} className="border-b">
- <td className="p-2">{item.prNo}</td>
- <td className="p-2">{item.materialCode}</td>
- <td className="p-2">{item.materialDescription}</td>
- <td className="p-2">{item.quantity} {item.uom}</td>
- <td className="p-2">
- {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"}
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- </div>
- )}
- </div>
- ) : null}
- </DialogContent>
- </Dialog>
+ <SessionDetailDialog
+ open={sessionDetailOpen}
+ onOpenChange={setSessionDetailOpen}
+ sessionDetail={sessionDetail}
+ isLoading={isLoadingDetail}
+ />
{/* Documents Sheet */}
- <Sheet open={documentsOpen} onOpenChange={setDocumentsOpen}>
- <SheetContent className="w-[600px] sm:w-[800px]">
- <SheetHeader>
- <SheetTitle>Documents & Comments</SheetTitle>
- <SheetDescription>
- Review documents and PDFTron comments
- </SheetDescription>
- </SheetHeader>
-
- {isLoadingDetail ? (
- <div className="p-8 text-center">Loading...</div>
- ) : sessionDetail?.documents ? (
- <Tabs defaultValue="buyer" className="mt-4">
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="buyer">Buyer Documents</TabsTrigger>
- <TabsTrigger value="vendor">Vendor Documents</TabsTrigger>
- </TabsList>
-
- <TabsContent value="buyer">
- <ScrollArea className="h-[calc(100vh-200px)]">
- <div className="space-y-2">
- {sessionDetail.documents
- .filter((doc: any) => doc.documentSource === "buyer")
- .map((doc: any) => (
- <div key={doc.documentId} className="border rounded-lg p-3">
- <div className="flex items-start justify-between">
- <div className="flex-1">
- <p className="font-medium text-sm">{doc.documentName}</p>
- <p className="text-xs text-muted-foreground">
- Type: {doc.documentType} | Status: {doc.reviewStatus}
- </p>
- </div>
- <div className="flex items-center gap-2">
- {doc.comments.totalCount > 0 && (
- <Badge variant={doc.comments.openCount > 0 ? "destructive" : "secondary"}>
- {doc.comments.openCount}/{doc.comments.totalCount} comments
- </Badge>
- )}
- <Button size="sm" variant="outline">
- View in PDFTron
- </Button>
- </div>
- </div>
- </div>
- ))}
- </div>
- </ScrollArea>
- </TabsContent>
-
- <TabsContent value="vendor">
- <ScrollArea className="h-[calc(100vh-200px)]">
- <div className="space-y-2">
- {sessionDetail.documents
- .filter((doc: any) => doc.documentSource === "vendor")
- .map((doc: any) => (
- <div key={doc.documentId} className="border rounded-lg p-3">
- <div className="flex items-start justify-between">
- <div className="flex-1">
- <p className="font-medium text-sm">{doc.documentName}</p>
- <p className="text-xs text-muted-foreground">
- Type: {doc.documentType} | Status: {doc.reviewStatus}
- </p>
- {doc.submittedAt && (
- <p className="text-xs text-muted-foreground">
- Submitted: {formatDate(doc.submittedAt, "KR")}
- </p>
- )}
- </div>
- <div className="flex items-center gap-2">
- <Button size="sm" variant="outline">
- Download
- </Button>
- <Button size="sm" variant="outline">
- Review
- </Button>
- </div>
- </div>
- </div>
- ))}
- </div>
- </ScrollArea>
- </TabsContent>
- </Tabs>
- ) : null}
- </SheetContent>
- </Sheet>
+ <DocumentsSheet
+ open={documentsOpen}
+ onOpenChange={setDocumentsOpen}
+ sessionDetail={sessionDetail}
+ isLoading={isLoadingDetail}
+ />
{/* PR Items Dialog */}
- <Dialog open={prItemsOpen} onOpenChange={setPrItemsOpen}>
- <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle>PR Items</DialogTitle>
- <DialogDescription>
- Purchase Request items for this RFQ
- </DialogDescription>
- </DialogHeader>
- {sessionDetail?.prItems && (
- <div className="border rounded-lg">
- <table className="w-full text-sm">
- <thead>
- <tr className="border-b bg-muted/50">
- <th className="text-left p-2">PR No</th>
- <th className="text-left p-2">Material Code</th>
- <th className="text-left p-2">Description</th>
- <th className="text-left p-2">Size</th>
- <th className="text-left p-2">Qty</th>
- <th className="text-left p-2">Unit</th>
- <th className="text-left p-2">Delivery</th>
- <th className="text-left p-2">Major</th>
- </tr>
- </thead>
- <tbody>
- {sessionDetail.prItems.map((item: any) => (
- <tr key={item.id} className="border-b hover:bg-muted/20">
- <td className="p-2">{item.prNo}</td>
- <td className="p-2">{item.materialCode}</td>
- <td className="p-2">{item.materialDescription}</td>
- <td className="p-2">{item.size || "-"}</td>
- <td className="p-2 text-right">{item.quantity}</td>
- <td className="p-2">{item.uom}</td>
- <td className="p-2">
- {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"}
- </td>
- <td className="p-2 text-center">
- {item.majorYn && <Badge variant="default">Major</Badge>}
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- )}
- </DialogContent>
- </Dialog>
+ <PrItemsDialog
+ open={prItemsOpen}
+ onOpenChange={setPrItemsOpen}
+ sessionDetail={sessionDetail}
+ isLoading={isLoadingDetail}
+ />
{/* Evaluation Dialog */}
- <Dialog open={evaluationOpen} onOpenChange={setEvaluationOpen}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>TBE Evaluation</DialogTitle>
- <DialogDescription>
- Enter evaluation result for {selectedSession?.sessionCode}
- </DialogDescription>
- </DialogHeader>
- <div className="space-y-4 mt-4">
- {/* Evaluation form would go here */}
- <p className="text-sm text-muted-foreground">
- Evaluation form to be implemented...
- </p>
- </div>
- </DialogContent>
- </Dialog>
+ <EvaluationDialog
+ open={evaluationOpen}
+ onOpenChange={setEvaluationOpen}
+ selectedSession={selectedSession}
+ sessionDetail={sessionDetail}
+
+ />
</>
)
} \ No newline at end of file
diff --git a/lib/tbe-last/vendor-tbe-service.ts b/lib/tbe-last/vendor-tbe-service.ts
new file mode 100644
index 00000000..8335eb4f
--- /dev/null
+++ b/lib/tbe-last/vendor-tbe-service.ts
@@ -0,0 +1,355 @@
+// lib/vendor-rfq-response/vendor-tbe-service-simplified.ts
+
+'use server'
+
+import { unstable_cache } from "next/cache"
+import db from "@/db/db"
+import { and, desc, asc, eq, sql, or } from "drizzle-orm"
+import { tbeLastView, rfqLastTbeSessions } from "@/db/schema"
+import { rfqPrItems } from "@/db/schema/rfqLast"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { revalidateTag } from "next/cache"
+// ==========================================
+// 간단한 벤더 Q&A 타입 정의
+// ==========================================
+export interface VendorQuestion {
+ id: string // UUID
+ category: "general" | "technical" | "commercial" | "delivery" | "quality" | "document" | "clarification"
+ question: string
+ askedAt: string
+ askedBy: number
+ askedByName?: string
+ answer?: string
+ answeredAt?: string
+ answeredBy?: number
+ answeredByName?: string
+ status: "open" | "answered" | "closed"
+ priority?: "high" | "normal" | "low"
+ attachments?: string[] // 파일 경로들
+}
+
+// ==========================================
+// 1. 벤더용 TBE 세션 목록 조회 (기존 뷰 활용)
+// ==========================================
+export async function getTBEforVendor(
+ input: any,
+ vendorId: number
+) {
+ return unstable_cache(
+ async () => {
+ const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
+ const limit = input.perPage ?? 10
+
+ // 벤더 필터링
+ const vendorWhere = eq(tbeLastView.vendorId, vendorId)
+
+ // 데이터 조회
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select()
+ .from(tbeLastView)
+ .where(vendorWhere)
+ .orderBy(desc(tbeLastView.createdAt))
+ .offset(offset)
+ .limit(limit)
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(tbeLastView)
+ .where(vendorWhere)
+
+ return [data, Number(count)]
+ })
+
+ const pageCount = Math.ceil(total / limit)
+ return { data: rows, pageCount }
+ },
+ [`vendor-tbe-sessions-${vendorId}`, JSON.stringify(input)],
+ {
+ revalidate: 60,
+ tags: [`vendor-tbe-sessions-${vendorId}`],
+ }
+ )()
+}
+
+// ==========================================
+// 2. 벤더 질문/코멘트 추가 (기존 필드 활용)
+// ==========================================
+export async function addVendorQuestion(
+ sessionId: number,
+ vendorId: number,
+ question: Omit<VendorQuestion, "id" | "askedAt">
+) {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id
+
+ // 권한 체크
+ const [tbeSession] = await db
+ .select()
+ .from(rfqLastTbeSessions)
+ .where(
+ and(
+ eq(rfqLastTbeSessions.id, sessionId),
+ eq(rfqLastTbeSessions.vendorId, vendorId)
+ )
+ )
+ .limit(1)
+
+ if (!tbeSession) {
+ throw new Error("권한이 없습니다")
+ }
+
+ // 기존 질문 로그 가져오기
+ const existingQuestions = tbeSession.vendorQuestionsLog || []
+
+ // 새 질문 추가
+ const newQuestion: VendorQuestion = {
+ id: crypto.randomUUID(),
+ ...question,
+ askedAt: new Date().toISOString(),
+ askedBy: userId,
+ status: "open"
+ }
+
+ // 업데이트
+ const [updated] = await db
+ .update(rfqLastTbeSessions)
+ .set({
+ vendorQuestionsLog: [...existingQuestions, newQuestion],
+ vendorRemarks: tbeSession.vendorRemarks
+ ? `${tbeSession.vendorRemarks}\n\n[${new Date().toLocaleString()}] ${question.question}`
+ : `[${new Date().toLocaleString()}] ${question.question}`,
+ updatedAt: new Date(),
+ updatedBy: userId
+ })
+ .where(eq(rfqLastTbeSessions.id, sessionId))
+ .returning()
+
+ // 캐시 무효화
+ revalidateTag(`vendor-tbe-sessions-${vendorId}`)
+ revalidateTag(`tbe-session-${sessionId}`)
+
+ return newQuestion
+}
+
+// ==========================================
+// 3. 구매자가 답변 추가
+// ==========================================
+export async function answerVendorQuestion(
+ sessionId: number,
+ questionId: string,
+ answer: string
+) {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id
+
+ // TBE 세션 조회
+ const [tbeSession] = await db
+ .select()
+ .from(rfqLastTbeSessions)
+ .where(eq(rfqLastTbeSessions.id, sessionId))
+ .limit(1)
+
+ if (!tbeSession) {
+ throw new Error("세션을 찾을 수 없습니다")
+ }
+
+ // 질문 로그 업데이트
+ const questions = (tbeSession.vendorQuestionsLog || []) as VendorQuestion[]
+ const updatedQuestions = questions.map(q => {
+ if (q.id === questionId) {
+ return {
+ ...q,
+ answer,
+ answeredAt: new Date().toISOString(),
+ answeredBy: userId,
+ status: "answered" as const
+ }
+ }
+ return q
+ })
+
+ // 업데이트
+ const [updated] = await db
+ .update(rfqLastTbeSessions)
+ .set({
+ vendorQuestionsLog: updatedQuestions,
+ updatedAt: new Date(),
+ updatedBy: userId
+ })
+ .where(eq(rfqLastTbeSessions.id, sessionId))
+ .returning()
+
+ // 캐시 무효화
+ revalidateTag(`tbe-session-${sessionId}`)
+
+ return updated
+}
+
+// ==========================================
+// 4. 벤더 질문 목록 조회
+// ==========================================
+export async function getVendorQuestions(
+ sessionId: number,
+ vendorId: number
+): Promise<VendorQuestion[]> {
+ // 권한 체크
+ const [tbeSession] = await db
+ .select()
+ .from(rfqLastTbeSessions)
+ .where(
+ and(
+ eq(rfqLastTbeSessions.id, sessionId),
+ eq(rfqLastTbeSessions.vendorId, vendorId)
+ )
+ )
+ .limit(1)
+
+ if (!tbeSession) {
+ return []
+ }
+
+ return (tbeSession.vendorQuestionsLog || []) as VendorQuestion[]
+}
+
+// ==========================================
+// 5. 벤더 의견 업데이트 (간단한 텍스트)
+// ==========================================
+export async function updateVendorRemarks(
+ sessionId: number,
+ vendorId: number,
+ remarks: string
+) {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ const userId = typeof session.user.id === 'string' ? parseInt(session.user.id) : session.user.id
+
+ // 권한 체크
+ const [tbeSession] = await db
+ .select()
+ .from(rfqLastTbeSessions)
+ .where(
+ and(
+ eq(rfqLastTbeSessions.id, sessionId),
+ eq(rfqLastTbeSessions.vendorId, vendorId)
+ )
+ )
+ .limit(1)
+
+ if (!tbeSession) {
+ throw new Error("권한이 없습니다")
+ }
+
+ // 업데이트
+ const [updated] = await db
+ .update(rfqLastTbeSessions)
+ .set({
+ vendorRemarks: remarks,
+ updatedAt: new Date(),
+ updatedBy: userId
+ })
+ .where(eq(rfqLastTbeSessions.id, sessionId))
+ .returning()
+
+ // 캐시 무효화
+ revalidateTag(`vendor-tbe-sessions-${vendorId}`)
+ revalidateTag(`tbe-session-${sessionId}`)
+
+ return updated
+}
+
+// ==========================================
+// 6. 통계 조회
+// ==========================================
+export async function getVendorQuestionStats(sessionId: number) {
+ const [tbeSession] = await db
+ .select()
+ .from(rfqLastTbeSessions)
+ .where(eq(rfqLastTbeSessions.id, sessionId))
+ .limit(1)
+
+ if (!tbeSession) {
+ return {
+ total: 0,
+ open: 0,
+ answered: 0,
+ closed: 0
+ }
+ }
+
+ const questions = (tbeSession.vendorQuestionsLog || []) as VendorQuestion[]
+
+ return {
+ total: questions.length,
+ open: questions.filter(q => q.status === "open").length,
+ answered: questions.filter(q => q.status === "answered").length,
+ closed: questions.filter(q => q.status === "closed").length,
+ highPriority: questions.filter(q => q.priority === "high").length
+ }
+}
+
+
+// ==========================================
+// 6. PR 아이템 조회 (벤더용)
+// ==========================================
+export async function getVendorPrItems(
+ rfqId: number
+ ) {
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다.");
+ }
+
+ const vendorId = session.user.companyId
+
+ // RFQ가 해당 벤더의 것인지 체크
+ const [tbeSession] = await db
+ .select()
+ .from(tbeLastView)
+ .where(
+ and(
+ eq(tbeLastView.rfqId, rfqId),
+ eq(tbeLastView.vendorId, vendorId)
+ )
+ )
+ .limit(1)
+
+ if (!tbeSession) {
+ return []
+ }
+
+ // PR 아이템 조회
+ const prItems = await db
+ .select({
+ id: rfqPrItems.id,
+ prNo: rfqPrItems.prNo,
+ prItem: rfqPrItems.prItem,
+ materialCode: rfqPrItems.materialCode,
+ materialCategory: rfqPrItems.materialCategory,
+ materialDescription: rfqPrItems.materialDescription,
+ size: rfqPrItems.size,
+ quantity: rfqPrItems.quantity,
+ uom: rfqPrItems.uom,
+ deliveryDate: rfqPrItems.deliveryDate,
+ majorYn: rfqPrItems.majorYn,
+ remarks: rfqPrItems.remark,
+ })
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, rfqId))
+ .orderBy(desc(rfqPrItems.majorYn), asc(rfqPrItems.prItem))
+
+ return prItems
+ } \ No newline at end of file
diff --git a/lib/tbe-last/vendor/tbe-table-columns.tsx b/lib/tbe-last/vendor/tbe-table-columns.tsx
new file mode 100644
index 00000000..6e40fe27
--- /dev/null
+++ b/lib/tbe-last/vendor/tbe-table-columns.tsx
@@ -0,0 +1,335 @@
+// lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
+
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { FileText, ListChecks, Eye } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { formatDate } from "@/lib/utils"
+import { TbeLastView } from "@/db/schema"
+
+interface GetColumnsProps {
+ onOpenEvaluationView: (session: TbeLastView) => void;
+ onOpenDocuments: (sessionId: number) => void;
+ onOpenPrItems: (rfqId: number) => void;
+}
+
+export function getColumns({
+ onOpenEvaluationView,
+ onOpenDocuments,
+ onOpenPrItems,
+}: GetColumnsProps): ColumnDef<TbeLastView>[] {
+
+ const columns: ColumnDef<TbeLastView>[] = [
+ // Select Column
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // TBE Session Code
+ {
+ accessorKey: "sessionCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE Code" />
+ ),
+ cell: ({ row }) => {
+ const sessionCode = row.original.sessionCode;
+ return (
+ <span className="font-medium">{sessionCode}</span>
+ );
+ },
+ size: 120,
+ },
+
+ // RFQ Code
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Code" />
+ ),
+ cell: ({ row }) => row.original.rfqCode,
+ size: 120,
+ },
+
+ // RFQ Title
+ {
+ accessorKey: "rfqTitle",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Title" />
+ ),
+ cell: ({ row }) => row.original.rfqTitle || "-",
+ size: 200,
+ },
+
+ // RFQ Due Date
+ {
+ accessorKey: "rfqDueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Due Date" />
+ ),
+ cell: ({ row }) => {
+ const date = row.original.rfqDueDate;
+ if (!date) return "-";
+
+ const daysUntilDue = Math.floor((new Date(date).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+ const isOverdue = daysUntilDue < 0;
+ const isUrgent = daysUntilDue <= 3 && daysUntilDue >= 0;
+
+ return (
+ <div className="flex flex-col">
+ <span className={`text-sm ${isOverdue ? 'text-red-600' : isUrgent ? 'text-orange-600' : ''}`}>
+ {formatDate(date, "KR")}
+ </span>
+ {isOverdue && (
+ <span className="text-xs text-red-600">Overdue</span>
+ )}
+ {isUrgent && (
+ <span className="text-xs text-orange-600">{daysUntilDue}일 남음</span>
+ )}
+ </div>
+ );
+ },
+ size: 100,
+ },
+
+ // Package Info
+ {
+ accessorKey: "packageNo",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Package" />
+ ),
+ cell: ({ row }) => {
+ const packageNo = row.original.packageNo;
+ const packageName = row.original.packageName;
+
+ if (!packageNo) return "-";
+
+ return (
+ <div className="flex flex-col">
+ <span className="font-medium">{packageNo}</span>
+ {packageName && (
+ <span className="text-xs text-muted-foreground">{packageName}</span>
+ )}
+ </div>
+ );
+ },
+ size: 150,
+ },
+
+ // Project Info
+ {
+ accessorKey: "projectCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Project" />
+ ),
+ cell: ({ row }) => {
+ const projectCode = row.original.projectCode;
+ const projectName = row.original.projectName;
+
+ if (!projectCode) return "-";
+
+ return (
+ <div className="flex flex-col">
+ <span className="font-medium">{projectCode}</span>
+ {projectName && (
+ <span className="text-xs text-muted-foreground">{projectName}</span>
+ )}
+ </div>
+ );
+ },
+ size: 150,
+ },
+
+ // 구매담당자
+ {
+ accessorKey: "picName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="구매담당자" />
+ ),
+ cell: ({ row }) => row.original.picName || "-",
+ size: 120,
+ },
+
+ // TBE Status
+ {
+ accessorKey: "sessionStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Status" />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.sessionStatus;
+
+ let variant: "default" | "secondary" | "outline" | "destructive" = "outline";
+
+ switch (status) {
+ case "준비중":
+ variant = "outline";
+ break;
+ case "진행중":
+ variant = "default";
+ break;
+ case "검토중":
+ variant = "secondary";
+ break;
+ case "완료":
+ variant = "default";
+ break;
+ case "보류":
+ variant = "destructive";
+ break;
+ }
+
+ return <Badge variant={variant}>{status}</Badge>;
+ },
+ size: 100,
+ },
+
+ // Evaluation Result
+ {
+ accessorKey: "evaluationResult",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Evaluation" />
+ ),
+ cell: ({ row }) => {
+ const result = row.original.evaluationResult;
+ const session = row.original;
+
+ if (!result) {
+ return (
+ <Badge variant="outline" className="text-muted-foreground">
+ Pending
+ </Badge>
+ );
+ }
+
+ let variant: "default" | "secondary" | "destructive" = "default";
+ let displayText = result;
+
+ switch (result) {
+ case "Acceptable":
+ variant = "default";
+ displayText = "Acceptable";
+ break;
+ case "Acceptable with Comment":
+ variant = "secondary";
+ displayText = "Conditional";
+ break;
+ case "Not Acceptable":
+ variant = "destructive";
+ displayText = "Not Acceptable";
+ break;
+ }
+
+ return (
+ <div className="flex items-center gap-1">
+ <Badge variant={variant}>{displayText}</Badge>
+ {result && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => onOpenEvaluationView(session)}
+ title="View evaluation details"
+ >
+ <Eye className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+ );
+ },
+ size: 150,
+ },
+
+ // PR Items
+ {
+ id: "prItems",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PR Items" />
+ ),
+ cell: ({ row }) => {
+ const rfqId = row.original.rfqId;
+ const totalCount = row.original.prItemsCount;
+ const majorCount = row.original.majorItemsCount;
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 px-2"
+ onClick={() => onOpenPrItems(rfqId)}
+ >
+ <ListChecks className="h-4 w-4 mr-1" />
+ <span className="text-xs">
+ {totalCount} ({majorCount})
+ </span>
+ </Button>
+ );
+ },
+ size: 100,
+ enableSorting: false,
+ },
+
+ // Documents (클릭하면 Documents Sheet 열림)
+ {
+ id: "documents",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Documents" />
+ ),
+ cell: ({ row }) => {
+ const sessionId = row.original.tbeSessionId;
+ const buyerDocs = Number(row.original.buyerDocumentsCount);
+ const vendorDocs = Number(row.original.vendorDocumentsCount);
+ const totalDocs = buyerDocs + vendorDocs;
+ const status = row.original.sessionStatus;
+
+ // 진행중 상태면 강조
+ const isActive = status === "진행중";
+
+ return (
+ <Button
+ variant={isActive ? "default" : "ghost"}
+ size="sm"
+ className="h-8 px-2"
+ onClick={() => onOpenDocuments(sessionId)}
+ title={isActive ? "문서 관리 (업로드/코멘트 가능)" : "문서 조회"}
+ >
+ <FileText className="h-4 w-4 mr-1" />
+ <span className="text-xs">
+ {totalDocs} (B:{buyerDocs}/V:{vendorDocs})
+ </span>
+ </Button>
+ );
+ },
+ size: 140,
+ enableSorting: false,
+ },
+ ];
+
+ return columns;
+} \ No newline at end of file
diff --git a/lib/tbe-last/vendor/tbe-table.tsx b/lib/tbe-last/vendor/tbe-table.tsx
new file mode 100644
index 00000000..d7ee0a06
--- /dev/null
+++ b/lib/tbe-last/vendor/tbe-table.tsx
@@ -0,0 +1,222 @@
+// lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
+
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { type DataTableAdvancedFilterField } from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns } from "./tbe-table-columns"
+import { TbeLastView } from "@/db/schema"
+import { getTBESessionDetail } from "@/lib/tbe-last/service"
+import { Button } from "@/components/ui/button"
+import { Download, RefreshCw, Upload } from "lucide-react"
+import { exportTableToExcel } from "@/lib/export"
+
+// Import Vendor-specific Dialogs
+import { VendorDocumentUploadDialog } from "./vendor-document-upload-dialog"
+import { VendorQADialog } from "./vendor-comment-dialog"
+import { VendorDocumentsSheet } from "./vendor-documents-sheet"
+import { VendorPrItemsDialog } from "./vendor-pr-items-dialog"
+import { getTBEforVendor } from "../vendor-tbe-service"
+
+interface TbeVendorTableProps {
+ promises: Promise<[
+ Awaited<ReturnType<typeof getTBEforVendor>>,
+ ]>
+}
+
+export function TbeVendorTable({ promises }: TbeVendorTableProps) {
+ const router = useRouter()
+ const [{ data, pageCount }] = React.use(promises)
+
+ // Dialog states
+ const [documentUploadOpen, setDocumentUploadOpen] = React.useState(false)
+ const [qaDialogOpen, setQADialogOpen] = React.useState(false)
+ const [evaluationViewOpen, setEvaluationViewOpen] = React.useState(false)
+ const [documentsOpen, setDocumentsOpen] = React.useState(false)
+ const [prItemsOpen, setPrItemsOpen] = React.useState(false)
+
+ const [selectedSessionId, setSelectedSessionId] = React.useState<number | null>(null)
+ const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
+ const [selectedSession, setSelectedSession] = React.useState<TbeLastView | null>(null)
+ const [sessionDetail, setSessionDetail] = React.useState<any>(null)
+ const [isLoadingDetail, setIsLoadingDetail] = React.useState(false)
+
+ // Load session detail when needed
+ const loadSessionDetail = React.useCallback(async (sessionId: number) => {
+ setIsLoadingDetail(true)
+ try {
+ const detail = await getTBESessionDetail(sessionId)
+ setSessionDetail(detail)
+ } catch (error) {
+ console.error("Failed to load session detail:", error)
+ } finally {
+ setIsLoadingDetail(false)
+ }
+ }, [])
+
+ // Handlers
+ const handleOpenDocumentUpload = React.useCallback((sessionId: number) => {
+ setSelectedSessionId(sessionId)
+ setDocumentUploadOpen(true)
+ loadSessionDetail(sessionId)
+ }, [loadSessionDetail])
+
+ const handleOpenComment = React.useCallback((sessionId: number) => {
+ setSelectedSessionId(sessionId)
+ setQADialogOpen(true)
+ loadSessionDetail(sessionId)
+ }, [loadSessionDetail])
+
+ const handleOpenEvaluationView = React.useCallback((session: TbeLastView) => {
+ setSelectedSession(session)
+ setEvaluationViewOpen(true)
+ loadSessionDetail(session.tbeSessionId)
+ }, [loadSessionDetail])
+
+ const handleOpenDocuments = React.useCallback((sessionId: number) => {
+ setSelectedSessionId(sessionId)
+ setDocumentsOpen(true)
+ loadSessionDetail(sessionId)
+ }, [loadSessionDetail])
+
+ const handleOpenPrItems = React.useCallback((rfqId: number) => {
+ setSelectedRfqId(rfqId)
+ setPrItemsOpen(true)
+ }, [])
+
+ const handleRefresh = React.useCallback(() => {
+ router.refresh()
+ }, [router])
+
+ // Table columns
+ const columns = React.useMemo(
+ () =>
+ getColumns({
+ onOpenDocumentUpload: handleOpenDocumentUpload,
+ onOpenComment: handleOpenComment,
+ onOpenEvaluationView: handleOpenEvaluationView,
+ onOpenDocuments: handleOpenDocuments,
+ onOpenPrItems: handleOpenPrItems,
+ }),
+ [handleOpenDocumentUpload, handleOpenComment, handleOpenEvaluationView, handleOpenDocuments, handleOpenPrItems]
+ )
+
+ // Filter fields
+ const filterFields: DataTableAdvancedFilterField<TbeLastView>[] = [
+ {
+ id: "sessionStatus",
+ label: "Status",
+ type: "select",
+ options: [
+ { label: "준비중", value: "준비중" },
+ { label: "진행중", value: "진행중" },
+ { label: "검토중", value: "검토중" },
+ { label: "보류", value: "보류" },
+ { label: "완료", value: "완료" },
+ ],
+ },
+ {
+ id: "evaluationResult",
+ label: "Result",
+ type: "select",
+ options: [
+ { label: "Pass", value: "pass" },
+ { label: "Conditional Pass", value: "conditional_pass" },
+ { label: "Non-Pass", value: "non_pass" },
+ { label: "Pending", value: "pending" },
+ ],
+ },
+ ]
+
+ // Data table
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.tbeSessionId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={filterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ className="gap-2"
+ >
+ <RefreshCw className="size-4" />
+ <span>Refresh</span>
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "vendor-tbe-sessions",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" />
+ <span>Export</span>
+ </Button>
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* Document Upload Dialog */}
+ <VendorDocumentUploadDialog
+ open={documentUploadOpen}
+ onOpenChange={setDocumentUploadOpen}
+ sessionId={selectedSessionId}
+ sessionDetail={sessionDetail}
+ onUploadSuccess={handleRefresh}
+ />
+
+ {/* Q&A Dialog */}
+ <VendorQADialog
+ open={qaDialogOpen}
+ onOpenChange={setQADialogOpen}
+ sessionId={selectedSessionId}
+ sessionDetail={sessionDetail}
+ onQuestionSubmit={handleRefresh}
+ />
+
+ {/* Documents Sheet */}
+ <VendorDocumentsSheet
+ open={documentsOpen}
+ onOpenChange={setDocumentsOpen}
+ sessionDetail={sessionDetail}
+ isLoading={isLoadingDetail}
+ />
+
+ {/* PR Items Dialog */}
+ <VendorPrItemsDialog
+ open={prItemsOpen}
+ onOpenChange={setPrItemsOpen}
+ rfqId={selectedRfqId}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/tbe-last/vendor/vendor-comment-dialog.tsx b/lib/tbe-last/vendor/vendor-comment-dialog.tsx
new file mode 100644
index 00000000..8aa8d97c
--- /dev/null
+++ b/lib/tbe-last/vendor/vendor-comment-dialog.tsx
@@ -0,0 +1,313 @@
+// lib/vendor-rfq-response/vendor-tbe-table/vendor-qa-dialog.tsx
+
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { toast } from "sonner"
+import {
+ MessageSquare,
+ Send,
+ Loader2,
+ Clock,
+ CheckCircle,
+ AlertCircle
+} from "lucide-react"
+import { formatDate } from "@/lib/utils"
+
+interface VendorQuestion {
+ id: string
+ category: string
+ question: string
+ askedAt: string
+ askedBy: number
+ askedByName?: string
+ answer?: string
+ answeredAt?: string
+ answeredBy?: number
+ answeredByName?: string
+ status: "open" | "answered" | "closed"
+ priority?: "high" | "normal" | "low"
+}
+
+interface VendorQADialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ sessionId: number | null
+ sessionDetail: any
+ onQuestionSubmit: () => void
+}
+
+export function VendorQADialog({
+ open,
+ onOpenChange,
+ sessionId,
+ sessionDetail,
+ onQuestionSubmit
+}: VendorQADialogProps) {
+
+ const [category, setCategory] = React.useState("general")
+ const [priority, setPriority] = React.useState("normal")
+ const [question, setQuestion] = React.useState("")
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [questions, setQuestions] = React.useState<VendorQuestion[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // Load questions when dialog opens
+ React.useEffect(() => {
+ if (open && sessionId) {
+ loadQuestions()
+ }
+ }, [open, sessionId])
+
+ const loadQuestions = async () => {
+ if (!sessionId) return
+
+ setIsLoading(true)
+ try {
+ const response = await fetch(`/api/tbe/sessions/${sessionId}/vendor-questions`)
+ if (response.ok) {
+ const data = await response.json()
+ setQuestions(data)
+ }
+ } catch (error) {
+ console.error("Failed to load questions:", error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // Submit question
+ const handleSubmit = async () => {
+ if (!sessionId || !question.trim()) {
+ toast.error("질문을 입력해주세요")
+ return
+ }
+
+ setIsSubmitting(true)
+
+ try {
+ const response = await fetch(`/api/tbe/sessions/${sessionId}/vendor-questions`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ category,
+ question,
+ priority
+ })
+ })
+
+ if (!response.ok) throw new Error("Failed to submit question")
+
+ toast.success("질문이 제출되었습니다")
+
+ // Reset form
+ setCategory("general")
+ setPriority("normal")
+ setQuestion("")
+
+ // Reload questions
+ await loadQuestions()
+
+ // Callback
+ onQuestionSubmit()
+
+ } catch (error) {
+ console.error("Question submission error:", error)
+ toast.error("질문 제출 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // Get status icon
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case "answered":
+ return <CheckCircle className="h-4 w-4 text-green-600" />
+ case "closed":
+ return <CheckCircle className="h-4 w-4 text-gray-600" />
+ default:
+ return <Clock className="h-4 w-4 text-orange-600" />
+ }
+ }
+
+ // Get priority color
+ const getPriorityColor = (priority?: string) => {
+ switch (priority) {
+ case "high":
+ return "destructive"
+ case "low":
+ return "secondary"
+ default:
+ return "default"
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-3xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>Q&A with Buyer</DialogTitle>
+ <DialogDescription>
+ {sessionDetail?.session?.sessionCode} - 구매자에게 질문하기
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* Previous Questions */}
+ {questions.length > 0 && (
+ <div>
+ <Label className="text-sm font-medium mb-2">Previous Q&A</Label>
+ <ScrollArea className="h-[200px] border rounded-lg p-3">
+ <div className="space-y-3">
+ {questions.map(q => (
+ <div key={q.id} className="border-b pb-3 last:border-0">
+ <div className="flex items-start justify-between mb-2">
+ <div className="flex items-center gap-2">
+ {getStatusIcon(q.status)}
+ <Badge variant="outline" className="text-xs">
+ {q.category}
+ </Badge>
+ {q.priority && q.priority !== "normal" && (
+ <Badge variant={getPriorityColor(q.priority)} className="text-xs">
+ {q.priority}
+ </Badge>
+ )}
+ </div>
+ <span className="text-xs text-muted-foreground">
+ {formatDate(q.askedAt, "KR")}
+ </span>
+ </div>
+
+ <div className="space-y-2">
+ <div className="text-sm">
+ <strong>Q:</strong> {q.question}
+ </div>
+
+ {q.answer && (
+ <div className="text-sm text-muted-foreground ml-4">
+ <strong>A:</strong> {q.answer}
+ <span className="text-xs ml-2">
+ ({formatDate(q.answeredAt!, "KR")})
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+ )}
+
+ {/* New Question Form */}
+ <div className="space-y-3">
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <Label htmlFor="category">Category</Label>
+ <Select value={category} onValueChange={setCategory}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="general">일반 문의</SelectItem>
+ <SelectItem value="technical">기술 관련</SelectItem>
+ <SelectItem value="commercial">상업 조건</SelectItem>
+ <SelectItem value="delivery">납기 관련</SelectItem>
+ <SelectItem value="quality">품질 관련</SelectItem>
+ <SelectItem value="document">문서 관련</SelectItem>
+ <SelectItem value="clarification">명확화 요청</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label htmlFor="priority">Priority</Label>
+ <Select value={priority} onValueChange={setPriority}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="high">High</SelectItem>
+ <SelectItem value="normal">Normal</SelectItem>
+ <SelectItem value="low">Low</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div>
+ <Label htmlFor="question">Your Question</Label>
+ <Textarea
+ id="question"
+ value={question}
+ onChange={(e) => setQuestion(e.target.value)}
+ placeholder="구매자에게 질문할 내용을 입력하세요..."
+ className="min-h-[100px]"
+ disabled={isSubmitting}
+ />
+ </div>
+
+ <div className="flex justify-end gap-2">
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={!question.trim() || isSubmitting}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ 제출 중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4 mr-2" />
+ 질문 제출
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+
+ {/* Info Box */}
+ <div className="p-3 bg-muted/50 rounded-lg">
+ <div className="flex items-start gap-2">
+ <AlertCircle className="h-4 w-4 text-muted-foreground mt-0.5" />
+ <p className="text-xs text-muted-foreground">
+ 제출된 질문은 구매담당자가 확인 후 답변을 제공합니다.
+ 긴급한 질문은 Priority를 High로 설정해주세요.
+ </p>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tbe-last/vendor/vendor-document-upload-dialog.tsx b/lib/tbe-last/vendor/vendor-document-upload-dialog.tsx
new file mode 100644
index 00000000..c6f6c3d5
--- /dev/null
+++ b/lib/tbe-last/vendor/vendor-document-upload-dialog.tsx
@@ -0,0 +1,326 @@
+// lib/vendor-rfq-response/vendor-tbe-table/vendor-document-upload-dialog.tsx
+
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { toast } from "sonner"
+import { Upload, FileText, X, Loader2 } from "lucide-react"
+import { uploadVendorDocument } from "@/lib/tbe-last/service"
+
+interface VendorDocumentUploadDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ sessionId: number | null
+ sessionDetail: any
+ onUploadSuccess: () => void
+}
+
+interface FileUpload {
+ id: string
+ file: File
+ documentType: string
+ description: string
+ status: "pending" | "uploading" | "success" | "error"
+ errorMessage?: string
+}
+
+export function VendorDocumentUploadDialog({
+ open,
+ onOpenChange,
+ sessionId,
+ sessionDetail,
+ onUploadSuccess
+}: VendorDocumentUploadDialogProps) {
+
+ const [files, setFiles] = React.useState<FileUpload[]>([])
+ const [isUploading, setIsUploading] = React.useState(false)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // Document types for vendor
+ const documentTypes = [
+ { value: "technical_spec", label: "Technical Specification" },
+ { value: "compliance_cert", label: "Compliance Certificate" },
+ { value: "test_report", label: "Test Report" },
+ { value: "drawing", label: "Drawing" },
+ { value: "datasheet", label: "Datasheet" },
+ { value: "quality_doc", label: "Quality Document" },
+ { value: "warranty", label: "Warranty Document" },
+ { value: "other", label: "Other" },
+ ]
+
+ // Handle file selection
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFiles = Array.from(e.target.files || [])
+
+ const newFiles: FileUpload[] = selectedFiles.map(file => ({
+ id: Math.random().toString(36).substr(2, 9),
+ file,
+ documentType: "technical_spec",
+ description: "",
+ status: "pending" as const
+ }))
+
+ setFiles(prev => [...prev, ...newFiles])
+
+ // Reset input
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""
+ }
+ }
+
+ // Remove file
+ const handleRemoveFile = (id: string) => {
+ setFiles(prev => prev.filter(f => f.id !== id))
+ }
+
+ // Update file details
+ const handleUpdateFile = (id: string, field: keyof FileUpload, value: string) => {
+ setFiles(prev => prev.map(f =>
+ f.id === id ? { ...f, [field]: value } : f
+ ))
+ }
+
+ // Upload all files
+ const handleUploadAll = async () => {
+ if (!sessionId || files.length === 0) return
+
+ setIsUploading(true)
+
+ try {
+ for (const fileUpload of files) {
+ if (fileUpload.status === "success") continue
+
+ // Update status to uploading
+ setFiles(prev => prev.map(f =>
+ f.id === fileUpload.id ? { ...f, status: "uploading" } : f
+ ))
+
+ try {
+ // Create FormData for upload
+ const formData = new FormData()
+ formData.append("file", fileUpload.file)
+ formData.append("sessionId", sessionId.toString())
+ formData.append("documentType", fileUpload.documentType)
+ formData.append("description", fileUpload.description)
+
+ // Upload file (API call)
+ const response = await fetch("/api/tbe/vendor-documents/upload", {
+ method: "POST",
+ body: formData
+ })
+
+ if (!response.ok) throw new Error("Upload failed")
+
+ // Update status to success
+ setFiles(prev => prev.map(f =>
+ f.id === fileUpload.id ? { ...f, status: "success" } : f
+ ))
+
+ } catch (error) {
+ // Update status to error
+ setFiles(prev => prev.map(f =>
+ f.id === fileUpload.id ? {
+ ...f,
+ status: "error",
+ errorMessage: error instanceof Error ? error.message : "Upload failed"
+ } : f
+ ))
+ }
+ }
+
+ // Check if all files uploaded successfully
+ const allSuccess = files.every(f => f.status === "success")
+
+ if (allSuccess) {
+ toast.success("모든 문서가 업로드되었습니다")
+ onUploadSuccess()
+ onOpenChange(false)
+ setFiles([])
+ } else {
+ const failedCount = files.filter(f => f.status === "error").length
+ toast.error(`${failedCount}개 문서 업로드 실패`)
+ }
+
+ } catch (error) {
+ console.error("Upload error:", error)
+ toast.error("문서 업로드 중 오류가 발생했습니다")
+ } finally {
+ setIsUploading(false)
+ }
+ }
+
+ // Get file size in readable format
+ const formatFileSize = (bytes: number) => {
+ if (bytes < 1024) return bytes + " B"
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB"
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>Upload Documents for TBE</DialogTitle>
+ <DialogDescription>
+ {sessionDetail?.session?.sessionCode} - Technical Bid Evaluation 문서 업로드
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* File Upload Area */}
+ <div className="border-2 border-dashed rounded-lg p-6 text-center">
+ <Upload className="h-12 w-12 mx-auto text-muted-foreground mb-2" />
+ <p className="text-sm text-muted-foreground mb-2">
+ 파일을 드래그하거나 클릭하여 선택하세요
+ </p>
+ <Input
+ ref={fileInputRef}
+ type="file"
+ multiple
+ onChange={handleFileSelect}
+ className="hidden"
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.zip"
+ />
+ <Button
+ variant="outline"
+ onClick={() => fileInputRef.current?.click()}
+ disabled={isUploading}
+ >
+ 파일 선택
+ </Button>
+ </div>
+
+ {/* Selected Files List */}
+ {files.length > 0 && (
+ <ScrollArea className="h-[300px] border rounded-lg p-4">
+ <div className="space-y-4">
+ {files.map(fileUpload => (
+ <div key={fileUpload.id} className="border rounded-lg p-4 space-y-3">
+ <div className="flex items-start justify-between">
+ <div className="flex items-center gap-2">
+ <FileText className="h-5 w-5 text-muted-foreground" />
+ <div>
+ <p className="font-medium text-sm">{fileUpload.file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {formatFileSize(fileUpload.file.size)}
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {fileUpload.status === "uploading" && (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ )}
+ {fileUpload.status === "success" && (
+ <Badge variant="default">Uploaded</Badge>
+ )}
+ {fileUpload.status === "error" && (
+ <Badge variant="destructive">Failed</Badge>
+ )}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveFile(fileUpload.id)}
+ disabled={isUploading}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+
+ {fileUpload.status !== "success" && (
+ <>
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <Label className="text-xs">Document Type</Label>
+ <Select
+ value={fileUpload.documentType}
+ onValueChange={(value) => handleUpdateFile(fileUpload.id, "documentType", value)}
+ disabled={isUploading}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {documentTypes.map(type => (
+ <SelectItem key={type.value} value={type.value}>
+ {type.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-xs">Description (Optional)</Label>
+ <Textarea
+ value={fileUpload.description}
+ onChange={(e) => handleUpdateFile(fileUpload.id, "description", e.target.value)}
+ placeholder="문서에 대한 설명을 입력하세요..."
+ className="min-h-[60px] text-xs"
+ disabled={isUploading}
+ />
+ </div>
+ </>
+ )}
+
+ {fileUpload.errorMessage && (
+ <p className="text-xs text-red-600">{fileUpload.errorMessage}</p>
+ )}
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleUploadAll}
+ disabled={files.length === 0 || isUploading}
+ >
+ {isUploading ? (
+ <>
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ 업로드 중...
+ </>
+ ) : (
+ <>
+ <Upload className="h-4 w-4 mr-2" />
+ 업로드 ({files.filter(f => f.status !== "success").length}개)
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tbe-last/vendor/vendor-documents-sheet.tsx b/lib/tbe-last/vendor/vendor-documents-sheet.tsx
new file mode 100644
index 00000000..775d18cd
--- /dev/null
+++ b/lib/tbe-last/vendor/vendor-documents-sheet.tsx
@@ -0,0 +1,602 @@
+// lib/vendor-rfq-response/vendor-tbe-table/vendor-documents-sheet.tsx
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+} from "@/components/ui/sheet"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { formatDate } from "@/lib/utils"
+import { downloadFile } from "@/lib/file-download"
+import {
+ FileText,
+ Eye,
+ Download,
+ Filter,
+ MessageSquare,
+ CheckCircle,
+ XCircle,
+ Clock,
+ AlertCircle,
+ Upload,
+ CheckCircle2,
+ Loader2,
+ AlertTriangle,
+ Trash2,
+} from "lucide-react"
+import { toast } from "sonner"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneUploadIcon,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListHeader,
+ FileListItem,
+ FileListIcon,
+ FileListInfo,
+ FileListName,
+ FileListSize,
+ FileListDescription,
+ FileListAction,
+} from "@/components/ui/file-list"
+import { useRouter } from "next/navigation"
+
+interface VendorDocumentsSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ sessionDetail: any
+ isLoading: boolean
+ onUploadSuccess?: () => void
+}
+
+// 업로드 큐
+type QueueItem = {
+ id: string
+ file: File
+ status: "queued" | "uploading" | "done" | "error"
+ progress: number
+ error?: string
+}
+
+function makeId() {
+ // Safari/구형 브라우저 대비 폴백
+ return (typeof crypto !== "undefined" && "randomUUID" in crypto)
+ ? crypto.randomUUID()
+ : Math.random().toString(36).slice(2) + Date.now().toString(36)
+}
+
+type CommentCount = { totalCount: number; openCount: number }
+type CountMap = Record<number, CommentCount>
+
+export function VendorDocumentsSheet({
+ open,
+ onOpenChange,
+ sessionDetail,
+ isLoading,
+ onUploadSuccess,
+}: VendorDocumentsSheetProps) {
+ const [sourceFilter, setSourceFilter] = React.useState<"all" | "buyer" | "vendor">("all")
+ const [searchTerm, setSearchTerm] = React.useState("")
+ const [queue, setQueue] = React.useState<QueueItem[]>([])
+ const router = useRouter()
+ const [commentCounts, setCommentCounts] = React.useState<CountMap>({}) // <-- 추가
+ const [countLoading, setCountLoading] = React.useState(false)
+
+
+ console.log(sessionDetail, "sessionDetail")
+
+ const allReviewIds = React.useMemo(() => {
+ const docs = sessionDetail?.documents ?? []
+ const ids = new Set<number>()
+ for (const d of docs) {
+ const id = Number(d?.documentReviewId)
+ if (Number.isFinite(id)) ids.add(id)
+ }
+ return Array.from(ids)
+ }, [sessionDetail?.documents])
+
+ // 배치로 카운트 로드
+ React.useEffect(() => {
+ let aborted = false
+ ; (async () => {
+ if (allReviewIds.length === 0) {
+ setCommentCounts({})
+ return
+ }
+ setCountLoading(true)
+ try {
+ // 너무 길어질 수 있으니 적당히 나눠서 호출(옵션)
+ const chunkSize = 100
+ const chunks: number[][] = []
+ for (let i = 0; i < allReviewIds.length; i += chunkSize) {
+ chunks.push(allReviewIds.slice(i, i + chunkSize))
+ }
+
+ const merged: CountMap = {}
+ for (const c of chunks) {
+ const qs = encodeURIComponent(c.join(","))
+ const res = await fetch(`/api/pdftron-comments/xfdf/count?ids=${qs}`, {
+ credentials: "include",
+ cache: "no-store",
+ })
+ if (!res.ok) throw new Error(`count api ${res.status}`)
+ const json = await res.json()
+ if (aborted) return
+ const data = (json?.data ?? {}) as Record<string, { totalCount: number; openCount: number }>
+ for (const [k, v] of Object.entries(data)) {
+ const idNum = Number(k)
+ if (Number.isFinite(idNum)) {
+ merged[idNum] = { totalCount: v.totalCount ?? 0, openCount: v.openCount ?? 0 }
+ }
+ }
+ }
+ if (!aborted) setCommentCounts(merged)
+ } catch (e) {
+ console.error("Failed to load comment counts", e)
+ } finally {
+ if (!aborted) setCountLoading(false)
+ }
+ })()
+ return () => {
+ aborted = true
+ }
+ }, [allReviewIds.join(",")]) // 의존성: id 목록이 바뀔 때만
+
+
+ // PDFTron 열기
+ const handleOpenPDFTron = (doc: any) => {
+ if (!doc.filePath) {
+ toast.error("파일 경로를 찾을 수 없습니다")
+ return
+ }
+ const params = new URLSearchParams({
+ filePath: doc.filePath,
+ documentId: String(doc.documentId ?? ""),
+ documentReviewId: String(doc.documentReviewId ?? ""),
+ sessionId: String(sessionDetail?.session?.tbeSessionId ?? ""),
+ documentName: doc.documentName || "",
+ mode: doc.documentSource === "vendor" ? "edit" : "comment",
+ })
+ window.open(`/pdftron-viewer?${params.toString()}`, "_blank")
+ }
+
+ const canOpenInPDFTron = (filePath: string) => {
+ console.log(filePath, "filePath")
+ if (!filePath) return false
+ const ext = filePath.split(".").pop()?.toLowerCase()
+ const supported = ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "tiff", "bmp"]
+ return !!ext && supported.includes(ext)
+ }
+
+ const handleDownload = async (doc: any) => {
+ if (!doc.filePath) {
+ toast.error("파일 경로를 찾을 수 없습니다")
+ return
+ }
+ await downloadFile(doc.filePath, doc.originalFileName || doc.documentName, {
+ action: "download",
+ showToast: true,
+ onError: (e) => console.error("Download error:", e),
+ })
+ }
+
+ // ---- 업로드 ----
+ const tbeSessionId = sessionDetail?.session?.tbeSessionId
+ const endpoint = tbeSessionId ? `/api/partners/tbe/${tbeSessionId}/documents` : null
+
+ const startUpload = React.useCallback((item: QueueItem) => {
+ if (!endpoint) {
+ toast.error("세션 정보가 준비되지 않았습니다. 잠시 후 다시 시도하세요.")
+ setQueue((prev) =>
+ prev.map((q) => (q.id === item.id ? { ...q, status: "error", error: "세션 없음" } : q))
+ )
+ return
+ }
+
+ setQueue((prev) =>
+ prev.map((q) => (q.id === item.id ? { ...q, status: "uploading", progress: 0 } : q))
+ )
+
+ try {
+ const fd = new FormData()
+ fd.append("documentType", "설계") // 필수값 없이 기본값
+ fd.append("documentName", item.file.name.replace(/\.[^.]+$/, ""))
+ fd.append("description", "")
+ fd.append("file", item.file)
+
+ const xhr = new XMLHttpRequest()
+ xhr.withCredentials = true // 동일 출처라면 문제 없지만 안전하게 명시
+ xhr.upload.onprogress = (e) => {
+ if (e.lengthComputable) {
+ const pct = Math.round((e.loaded / e.total) * 100)
+ setQueue((prev) =>
+ prev.map((q) => (q.id === item.id ? { ...q, progress: pct } : q))
+ )
+ }
+ }
+ xhr.onreadystatechange = () => {
+ if (xhr.readyState === 4) {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ setQueue((prev) =>
+ prev.map((q) => (q.id === item.id ? { ...q, status: "done", progress: 100 } : q))
+ )
+ toast.success(`업로드 완료: ${item.file.name}`)
+ onUploadSuccess?.()
+ } else {
+ const err = (() => { try { return JSON.parse(xhr.responseText)?.error } catch { return null } })()
+ || `서버 오류 (${xhr.status})`
+ setQueue((prev) =>
+ prev.map((q) => (q.id === item.id ? { ...q, status: "error", error: err } : q))
+ )
+ toast.error(err)
+ }
+ }
+ }
+
+ xhr.open("POST", endpoint)
+ // Content-Type 수동 지정 금지 (XHR이 multipart 경계 자동 설정)
+ xhr.send(fd)
+
+ if (xhr.status >= 200 && xhr.status < 300) {
+ setQueue((prev) =>
+ prev.map((q) => (q.id === item.id ? { ...q, status: "done", progress: 100 } : q))
+ )
+ toast.success(`업로드 완료: ${item.file.name}`)
+ onUploadSuccess?.()
+ router.refresh()
+
+ // ✅ 1.5초 뒤 자동 제거 (원하면 시간 조절)
+ setTimeout(() => {
+ setQueue((prev) => prev.filter(q => q.id !== item.id))
+ }, 1500)
+ }
+
+ } catch (e: any) {
+ setQueue((prev) =>
+ prev.map((q) => (q.id === item.id ? { ...q, status: "error", error: e?.message || "업로드 실패" } : q))
+ )
+ toast.error(e?.message || "업로드 실패")
+ }
+ }, [endpoint, onUploadSuccess])
+
+ const lastBatchRef = React.useRef<string>("")
+
+ function batchSig(files: File[]) {
+ return files.map(f => `${f.name}:${f.size}:${f.lastModified}`).join("|")
+ }
+
+ const handleDrop = React.useCallback((filesOrEvent: any) => {
+ let files: File[] = []
+ if (Array.isArray(filesOrEvent)) {
+ files = filesOrEvent
+ } else if (filesOrEvent?.target?.files) {
+ files = Array.from(filesOrEvent.target.files as FileList)
+ } else if (filesOrEvent?.dataTransfer?.files) {
+ files = Array.from(filesOrEvent.dataTransfer.files as FileList)
+ }
+ if (!files.length) return
+
+ // 🔒 중복 배치 방지
+ const sig = batchSig(files)
+ if (sig === lastBatchRef.current) return
+ lastBatchRef.current = sig
+ // 너무 오래 잠기지 않도록 약간 뒤에 초기화
+ setTimeout(() => { if (lastBatchRef.current === sig) lastBatchRef.current = "" }, 500)
+
+ const items: QueueItem[] = files.map((f) => ({
+ id: makeId(),
+ file: f,
+ status: "queued",
+ progress: 0,
+ }))
+ setQueue((prev) => [...items, ...prev])
+ items.forEach((it) => startUpload(it))
+ }, [startUpload])
+
+ const removeFromQueue = (id: string) => {
+ setQueue((prev) => prev.filter((q) => q.id !== id))
+ }
+
+ React.useEffect(() => {
+ if (!open) {
+ setQueue([])
+ lastBatchRef.current = ""
+ }
+ }, [open])
+
+
+ // ---- 목록 필터 ----
+ const filteredDocuments = React.useMemo(() => {
+ const docs = sessionDetail?.documents ?? []
+ return docs.filter((doc: any) => {
+ if (sourceFilter !== "all" && doc.documentSource !== sourceFilter) return false
+ if (searchTerm) {
+ const s = searchTerm.toLowerCase()
+ return (
+ doc.documentName?.toLowerCase().includes(s) ||
+ doc.documentType?.toLowerCase().includes(s)
+ )
+ }
+ return true
+ })
+ }, [sessionDetail?.documents, sourceFilter, searchTerm])
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[1200px] sm:w-[1200px] max-w-[90vw]" style={{ width: 1200, maxWidth: "90vw" }}>
+ <SheetHeader>
+ <SheetTitle>Document Repository</SheetTitle>
+ <SheetDescription>TBE 관련 문서 조회, 다운로드 및 업로드</SheetDescription>
+ </SheetHeader>
+
+ {/* 업로드 안내 (세션 상태) */}
+ {sessionDetail?.session?.sessionStatus !== "진행중" && (
+ <div className="mt-3 mb-3 rounded-md border border-dashed p-3 text-sm text-muted-foreground">
+ 현재 세션 상태가 <b>{sessionDetail?.session?.sessionStatus}</b> 입니다.
+ 파일을 업로드하면 서버 정책에 따라 상태가 <b>진행중</b>으로 전환될 수 있어요.
+ </div>
+ )}
+
+ {/* --- 드롭존 영역 --- */}
+ <div className="mb-4 rounded-lg border border-dashed">
+ <Dropzone onDrop={handleDrop}>
+ <DropzoneZone className="py-8">
+ <DropzoneUploadIcon />
+ <DropzoneTitle>파일을 여기에 드롭하거나 클릭해서 선택하세요</DropzoneTitle>
+ <DropzoneDescription>
+ PDF, Office, 이미지 등 대용량(최대 1GB)도 지원합니다
+ </DropzoneDescription>
+ <DropzoneInput
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.tiff,.bmp"
+ multiple
+ />
+ </DropzoneZone>
+ </Dropzone>
+
+ {/* 업로드 큐/진행상태 */}
+ {queue.length > 0 && (
+ <div className="p-3">
+ <FileList>
+ <FileListHeader>업로드 큐</FileListHeader>
+ {queue.map((q) => (
+ <FileListItem key={q.id}>
+ <FileListIcon>
+ {q.status === "done" ? (
+ <CheckCircle2 className="h-5 w-5" />
+ ) : q.status === "error" ? (
+ <AlertTriangle className="h-5 w-5" />
+ ) : (
+ <Loader2 className="h-5 w-5 animate-spin" />
+ )}
+ </FileListIcon>
+ <FileListInfo>
+ <FileListName>
+ {q.file.name}
+ {q.status === "uploading" && ` · ${q.progress}%`}
+ </FileListName>
+ <FileListDescription>
+ {q.status === "queued" && "대기 중"}
+ {q.status === "uploading" && "업로드 중"}
+ {q.status === "done" && "완료"}
+ {q.status === "error" && (q.error || "실패")}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListSize>{q.file.size}</FileListSize>
+ <FileListAction>
+ {(q.status === "done" || q.status === "error") && (
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => removeFromQueue(q.id)}
+ title="제거"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ )}
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ )}
+ </div>
+
+ {/* 필터 & 검색 */}
+ <div className="flex items-center gap-4 mt-4 mb-4">
+ <div className="flex items-center gap-2">
+ <Filter className="h-4 w-4 text-muted-foreground" />
+ <Select value={sourceFilter} onValueChange={(v: any) => setSourceFilter(v)}>
+ <SelectTrigger className="w-[150px]">
+ <SelectValue placeholder="Filter by source" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All Documents</SelectItem>
+ <SelectItem value="buyer">Buyer Documents</SelectItem>
+ <SelectItem value="vendor">My Documents</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Input
+ placeholder="Search documents..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="max-w-sm"
+ />
+
+ <div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground">
+ <Badge variant="outline">Total: {filteredDocuments.length}</Badge>
+ {sessionDetail?.documents && (
+ <>
+ <Badge variant="secondary">
+ Buyer: {sessionDetail.documents.filter((d: any) => d.documentSource === "buyer").length}
+ </Badge>
+ <Badge variant="secondary">
+ My Docs: {sessionDetail.documents.filter((d: any) => d.documentSource === "vendor").length}
+ </Badge>
+ </>
+ )}
+ </div>
+ </div>
+
+ {/* 문서 테이블 */}
+ {isLoading ? (
+ <div className="p-8 text-center">Loading...</div>
+ ) : (
+ <ScrollArea className="h-[calc(100vh-250px)]">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[80px]">Source</TableHead>
+ <TableHead>Document Name</TableHead>
+ <TableHead className="w-[120px]">Type</TableHead>
+ <TableHead className="w-[100px]">Review Status</TableHead>
+ <TableHead className="w-[120px]">Comments</TableHead>
+ <TableHead className="w-[150px]">Uploaded</TableHead>
+ <TableHead className="w-[100px] text-right">Actions</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {filteredDocuments.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={7} className="text-center text-muted-foreground">
+ No documents found
+ </TableCell>
+ </TableRow>
+ ) : (
+ filteredDocuments.map((doc: any) => (
+ <TableRow key={doc.documentReviewId || doc.documentId}>
+ <TableCell>
+ <Badge variant={doc.documentSource === "buyer" ? "default" : "secondary"}>
+ {doc.documentSource === "buyer" ? "Buyer" : "Vendor"}
+ </Badge>
+ </TableCell>
+
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium">{doc.documentName}</span>
+ </div>
+ </TableCell>
+
+ <TableCell>
+ <span className="text-sm">{doc.documentType}</span>
+ </TableCell>
+
+ <TableCell>
+ {doc.documentSource === "vendor" && doc.reviewStatus ? (
+ <div className="flex items-center gap-1">
+ {(() => {
+ switch (doc.reviewStatus) {
+ case "승인": return <CheckCircle className="h-4 w-4 text-green-600" />
+ case "반려": return <XCircle className="h-4 w-4 text-red-600" />
+ case "보류": return <AlertCircle className="h-4 w-4 text-yellow-600" />
+ default: return <Clock className="h-4 w-4 text-gray-400" />
+ }
+ })()}
+ <span className="text-sm">{doc.reviewStatus}</span>
+ </div>
+ ) : (
+ <span className="text-sm text-muted-foreground">-</span>
+ )}
+ </TableCell>
+
+ <TableCell>
+ {(() => {
+ const id = Number(doc.documentReviewId)
+ const counts = Number.isFinite(id) ? commentCounts[id] : undefined
+ if (countLoading && !counts) {
+ return <span className="text-xs text-muted-foreground">Loading…</span>
+ }
+ if (!counts || counts.totalCount === 0) {
+ return <span className="text-muted-foreground text-xs">-</span>
+ }
+ return (
+ <div className="flex items-center gap-1">
+ <MessageSquare className="h-3 w-3" />
+ <span className="text-xs">
+ {counts.totalCount}
+ {counts.openCount > 0 && (
+ <span className="text-orange-600 ml-1">
+ ({counts.openCount} open)
+ </span>
+ )}
+ </span>
+ </div>
+ )
+ })()}
+ </TableCell>
+
+ <TableCell>
+ <span className="text-xs text-muted-foreground">
+ {doc.uploadedAt
+ ? formatDate(doc.uploadedAt, "KR")
+ : doc.submittedAt
+ ? formatDate(doc.submittedAt, "KR")
+ : "-"}
+ </span>
+ </TableCell>
+
+ <TableCell className="text-right">
+ <div className="flex items-center justify-end gap-1">
+ {canOpenInPDFTron(doc.filePath) && (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => handleOpenPDFTron(doc)}
+ className="h-8 px-2"
+ title={"View & Comment"}
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ )}
+
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => handleDownload(doc)}
+ className="h-8 px-2"
+ title="Download document"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ </TableCell>
+ </TableRow>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+ )}
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/tbe-last/vendor/vendor-evaluation-view-dialog.tsx b/lib/tbe-last/vendor/vendor-evaluation-view-dialog.tsx
new file mode 100644
index 00000000..d20646b6
--- /dev/null
+++ b/lib/tbe-last/vendor/vendor-evaluation-view-dialog.tsx
@@ -0,0 +1,250 @@
+// lib/vendor-rfq-response/vendor-tbe-table/vendor-evaluation-view-dialog.tsx
+
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ CheckCircle,
+ XCircle,
+ AlertCircle,
+ FileText,
+ Package,
+ DollarSign,
+ MessageSquare
+} from "lucide-react"
+import { formatDate } from "@/lib/utils"
+
+interface VendorEvaluationViewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedSession: any
+ sessionDetail: any
+}
+
+export function VendorEvaluationViewDialog({
+ open,
+ onOpenChange,
+ selectedSession,
+ sessionDetail
+}: VendorEvaluationViewDialogProps) {
+
+ // Get evaluation icon
+ const getEvaluationIcon = (result: string | null) => {
+ switch (result) {
+ case "pass":
+ return <CheckCircle className="h-5 w-5 text-green-600" />
+ case "conditional_pass":
+ return <AlertCircle className="h-5 w-5 text-yellow-600" />
+ case "non_pass":
+ return <XCircle className="h-5 w-5 text-red-600" />
+ default:
+ return null
+ }
+ }
+
+ // Get result display text
+ const getResultDisplay = (result: string | null) => {
+ switch (result) {
+ case "pass":
+ return { text: "Pass", variant: "default" as const }
+ case "conditional_pass":
+ return { text: "Conditional Pass", variant: "secondary" as const }
+ case "non_pass":
+ return { text: "Non-Pass", variant: "destructive" as const }
+ default:
+ return { text: "Pending", variant: "outline" as const }
+ }
+ }
+
+ const resultDisplay = getResultDisplay(selectedSession?.evaluationResult)
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>TBE Evaluation Result</DialogTitle>
+ <DialogDescription>
+ {selectedSession?.sessionCode} - Technical Bid Evaluation 결과
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="h-[500px] pr-4">
+ <div className="space-y-6">
+ {/* Overall Result */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-3">
+ <h3 className="font-medium">Overall Evaluation Result</h3>
+ <div className="flex items-center gap-2">
+ {getEvaluationIcon(selectedSession?.evaluationResult)}
+ <Badge variant={resultDisplay.variant} className="text-sm">
+ {resultDisplay.text}
+ </Badge>
+ </div>
+ </div>
+
+ {selectedSession?.evaluationResult === "conditional_pass" && (
+ <div className="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
+ <p className="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">
+ Conditions to be fulfilled:
+ </p>
+ <p className="text-sm text-yellow-700 dark:text-yellow-300">
+ {sessionDetail?.session?.conditionalRequirements || "조건부 요구사항이 명시되지 않았습니다."}
+ </p>
+ {sessionDetail?.session?.conditionsFulfilled !== undefined && (
+ <div className="mt-2">
+ <Badge variant={sessionDetail.session.conditionsFulfilled ? "default" : "outline"}>
+ {sessionDetail.session.conditionsFulfilled ? "Conditions Fulfilled" : "Pending Fulfillment"}
+ </Badge>
+ </div>
+ )}
+ </div>
+ )}
+
+ {selectedSession?.evaluationResult === "non_pass" && (
+ <div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
+ <p className="text-sm text-red-700 dark:text-red-300">
+ 기술 평가 기준을 충족하지 못했습니다. 자세한 내용은 아래 평가 요약을 참고해주세요.
+ </p>
+ </div>
+ )}
+ </div>
+
+ {/* Technical Summary */}
+ {sessionDetail?.session?.technicalSummary && (
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Package className="h-5 w-5 text-muted-foreground" />
+ <h3 className="font-medium">Technical Evaluation Summary</h3>
+ </div>
+ <p className="text-sm text-muted-foreground whitespace-pre-wrap">
+ {sessionDetail.session.technicalSummary}
+ </p>
+ </div>
+ )}
+
+ {/* Commercial Summary */}
+ {sessionDetail?.session?.commercialSummary && (
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <DollarSign className="h-5 w-5 text-muted-foreground" />
+ <h3 className="font-medium">Commercial Evaluation Summary</h3>
+ </div>
+ <p className="text-sm text-muted-foreground whitespace-pre-wrap">
+ {sessionDetail.session.commercialSummary}
+ </p>
+ </div>
+ )}
+
+ {/* Overall Remarks */}
+ {sessionDetail?.session?.overallRemarks && (
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <MessageSquare className="h-5 w-5 text-muted-foreground" />
+ <h3 className="font-medium">Overall Remarks</h3>
+ </div>
+ <p className="text-sm text-muted-foreground whitespace-pre-wrap">
+ {sessionDetail.session.overallRemarks}
+ </p>
+ </div>
+ )}
+
+ {/* Approval Information */}
+ {sessionDetail?.session?.approvedAt && (
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <FileText className="h-5 w-5 text-muted-foreground" />
+ <h3 className="font-medium">Approval Information</h3>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <p className="text-muted-foreground">Approved By</p>
+ <p className="font-medium">{sessionDetail.session.approvedBy || "-"}</p>
+ </div>
+ <div>
+ <p className="text-muted-foreground">Approved Date</p>
+ <p className="font-medium">
+ {formatDate(sessionDetail.session.approvedAt, "KR")}
+ </p>
+ </div>
+ {sessionDetail.session.approvalRemarks && (
+ <div className="col-span-2">
+ <p className="text-muted-foreground mb-1">Approval Remarks</p>
+ <p className="font-medium">{sessionDetail.session.approvalRemarks}</p>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Session Information */}
+ <div className="border rounded-lg p-4">
+ <h3 className="font-medium mb-3">Session Information</h3>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <p className="text-muted-foreground">Status</p>
+ <Badge>{selectedSession?.sessionStatus}</Badge>
+ </div>
+ <div>
+ <p className="text-muted-foreground">Created Date</p>
+ <p className="font-medium">
+ {selectedSession?.createdAt ? formatDate(selectedSession.createdAt, "KR") : "-"}
+ </p>
+ </div>
+ {sessionDetail?.session?.actualStartDate && (
+ <div>
+ <p className="text-muted-foreground">Start Date</p>
+ <p className="font-medium">
+ {formatDate(sessionDetail.session.actualStartDate, "KR")}
+ </p>
+ </div>
+ )}
+ {sessionDetail?.session?.actualEndDate && (
+ <div>
+ <p className="text-muted-foreground">End Date</p>
+ <p className="font-medium">
+ {formatDate(sessionDetail.session.actualEndDate, "KR")}
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* Next Steps (for vendor) */}
+ {selectedSession?.evaluationResult && (
+ <div className="border rounded-lg p-4 bg-muted/50">
+ <h3 className="font-medium mb-3">Next Steps</h3>
+ {selectedSession.evaluationResult === "pass" && (
+ <p className="text-sm text-muted-foreground">
+ 기술 평가를 통과하셨습니다. 상업 협상 단계로 진행될 예정입니다.
+ 구매담당자가 추가 안내를 제공할 것입니다.
+ </p>
+ )}
+ {selectedSession.evaluationResult === "conditional_pass" && (
+ <p className="text-sm text-muted-foreground">
+ 조건부 통과되었습니다. 명시된 조건을 충족하신 후 최종 승인을 받으실 수 있습니다.
+ 조건 충족을 위한 추가 문서나 설명을 제출해주세요.
+ </p>
+ )}
+ {selectedSession.evaluationResult === "non_pass" && (
+ <p className="text-sm text-muted-foreground">
+ 안타깝게도 이번 기술 평가를 통과하지 못하셨습니다.
+ 평가 요약 내용을 참고하시어 향후 입찰에 반영해주시기 바랍니다.
+ </p>
+ )}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tbe-last/vendor/vendor-pr-items-dialog.tsx b/lib/tbe-last/vendor/vendor-pr-items-dialog.tsx
new file mode 100644
index 00000000..e4b03e6d
--- /dev/null
+++ b/lib/tbe-last/vendor/vendor-pr-items-dialog.tsx
@@ -0,0 +1,253 @@
+// lib/vendor-rfq-response/vendor-tbe-table/vendor-pr-items-dialog.tsx
+
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { formatDate } from "@/lib/utils"
+import { Download, Package, AlertCircle } from "lucide-react"
+import { toast } from "sonner"
+import { exportDataToExcel } from "@/lib/export-to-excel"
+import { getVendorPrItems } from "../vendor-tbe-service"
+
+interface VendorPrItemsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ rfqId: number | null
+}
+
+interface PrItem {
+ id: number
+ prNo: string
+ prItem: string
+ materialCode: string
+ materialDescription: string
+ size?: string
+ quantity: number
+ uom: string
+ deliveryDate?: string
+ majorYn: boolean
+ specifications?: string
+ remarks?: string
+}
+
+export function VendorPrItemsDialog({
+ open,
+ onOpenChange,
+ rfqId
+}: VendorPrItemsDialogProps) {
+
+ const [prItems, setPrItems] = React.useState<PrItem[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // Load PR items when dialog opens
+ React.useEffect(() => {
+ if (open && rfqId) {
+ loadPrItems()
+ }
+ }, [open, rfqId])
+
+ const loadPrItems = async () => {
+ if (!rfqId) return
+
+ setIsLoading(true)
+ try {
+ const data = await getVendorPrItems(rfqId)
+
+ setPrItems(data)
+
+ } catch (error) {
+ console.error("Failed to load PR items:", error)
+ toast.error("Error loading PR items")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // Export to Excel
+ const handleExport = async () => {
+ if (prItems.length === 0) {
+ toast.error("No items to export")
+ return
+ }
+
+ try {
+ // Prepare data for export
+ const exportData = prItems.map(item => ({
+ "PR No": item.prNo || "-",
+ "PR Item": item.prItem || "-",
+ "Material Code": item.materialCode || "-",
+ "Description": item.materialDescription || "-",
+ "Size": item.size || "-",
+ "Quantity": item.quantity,
+ "Unit": item.uom || "-",
+ "Delivery Date": item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-",
+ "Major Item": item.majorYn ? "Yes" : "No",
+ "Specifications": item.specifications || "-",
+ "Remarks": item.remarks || "-"
+ }))
+
+ // Export using new utility
+ await exportDataToExcel(exportData, {
+ filename: `pr-items-${rfqId}`,
+ sheetName: "PR Items",
+ autoFilter: true,
+ freezeHeader: true
+ })
+
+ toast.success("Excel file exported successfully")
+ } catch (error) {
+ console.error("Export error:", error)
+ toast.error("Failed to export Excel file")
+ }
+ }
+
+ // Statistics
+ const statistics = React.useMemo(() => {
+ const totalItems = prItems.length
+ const majorItems = prItems.filter(item => item.majorYn).length
+ const minorItems = totalItems - majorItems
+
+ return { totalItems, majorItems, minorItems }
+ }, [prItems])
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[80vh]">
+ <DialogHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <DialogTitle>Purchase Request Items</DialogTitle>
+ <DialogDescription>
+ RFQ에 포함된 구매 요청 아이템 목록
+ </DialogDescription>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ disabled={prItems.length === 0}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ Export
+ </Button>
+ </div>
+ </DialogHeader>
+
+ {/* Statistics */}
+ <div className="flex items-center gap-4 py-2">
+ <Badge variant="outline" className="flex items-center gap-1">
+ <Package className="h-3 w-3" />
+ Total: {statistics.totalItems}
+ </Badge>
+ <Badge variant="default" className="flex items-center gap-1">
+ <AlertCircle className="h-3 w-3" />
+ Major: {statistics.majorItems}
+ </Badge>
+ <Badge variant="secondary">
+ Minor: {statistics.minorItems}
+ </Badge>
+ </div>
+
+ {/* PR Items Table */}
+ {isLoading ? (
+ <div className="p-8 text-center">Loading PR items...</div>
+ ) : prItems.length === 0 ? (
+ <div className="p-8 text-center text-muted-foreground">
+ No PR items available
+ </div>
+ ) : (
+ <ScrollArea className="h-[400px]">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[100px]">PR No</TableHead>
+ <TableHead className="w-[80px]">Item</TableHead>
+ <TableHead className="w-[120px]">Material Code</TableHead>
+ <TableHead>Description</TableHead>
+ <TableHead className="w-[80px]">Size</TableHead>
+ <TableHead className="w-[80px] text-right">Qty</TableHead>
+ <TableHead className="w-[60px]">Unit</TableHead>
+ <TableHead className="w-[100px]">Delivery</TableHead>
+ <TableHead className="w-[80px] text-center">Major</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {prItems.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className="font-medium">{item.prNo || "-"}</TableCell>
+ <TableCell>{item.prItem || "-"}</TableCell>
+ <TableCell>
+ <span className="font-mono text-xs">{item.materialCode || "-"}</span>
+ </TableCell>
+ <TableCell>
+ <div>
+ <p className="text-sm">{item.materialDescription || "-"}</p>
+ {item.remarks && (
+ <p className="text-xs text-muted-foreground mt-1">
+ {item.remarks}
+ </p>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>{item.size || "-"}</TableCell>
+ <TableCell className="text-right font-medium">
+ {item.quantity.toLocaleString()}
+ </TableCell>
+ <TableCell>{item.uom || "-"}</TableCell>
+ <TableCell>
+ {item.deliveryDate ? (
+ <span className="text-sm">
+ {formatDate(item.deliveryDate, "KR")}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )}
+ </TableCell>
+ <TableCell className="text-center">
+ {item.majorYn ? (
+ <Badge variant="default" className="text-xs">
+ Major
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="text-xs">
+ Minor
+ </Badge>
+ )}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+ )}
+
+ {/* Footer Note */}
+ <div className="mt-4 p-3 bg-muted/50 rounded-lg">
+ <p className="text-xs text-muted-foreground">
+ <strong>Note:</strong> Major items은 기술 평가의 주요 대상이며,
+ 모든 기술 요구사항을 충족해야 합니다.
+ 각 아이템의 세부 사양은 RFQ 문서를 참조해주세요.
+ </p>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/users/auth/verifyCredentails.ts b/lib/users/auth/verifyCredentails.ts
index 8cb3c434..b3dcd270 100644
--- a/lib/users/auth/verifyCredentails.ts
+++ b/lib/users/auth/verifyCredentails.ts
@@ -6,7 +6,7 @@ import crypto from 'crypto';
// (처리 불필요) 키 암호화를 위한 fs 모듈 사용, 형제 경로 사용하며 public 경로 아니므로 파일이 노출되지 않음.
import fs from 'fs';
import path from 'path';
-import { eq, and, desc, gte, count } from 'drizzle-orm';
+import { eq, and, desc, gte, count ,sql } from 'drizzle-orm';
import db from '@/db/db';
import {
users,
@@ -291,7 +291,7 @@ export async function verifyExternalCredentials(
.from(users)
.where(
and(
- eq(users.email, username),
+ sql`LOWER(${users.email}) = LOWER(${username})`, // 대소문자 구분 없이 비교
eq(users.isActive, true) // 활성 유저만
)
)
diff --git a/lib/users/repository.ts b/lib/users/repository.ts
index 121a1eaa..46ee1e48 100644
--- a/lib/users/repository.ts
+++ b/lib/users/repository.ts
@@ -2,7 +2,7 @@
import db from '@/db/db';
import { users, otps, type User, Role, roles, userRoles } from '@/db/schema/users';
import { Otp } from '@/types/user';
-import { eq,and ,asc} from 'drizzle-orm';
+import { eq,and ,asc,sql} from 'drizzle-orm';
// 모든 사용자 조회
export const getAllUsers = async (): Promise<User[]> => {
@@ -55,12 +55,13 @@ export const getUserByEmail = async (
): Promise<User | null> => {
const { includeInactive = false } = options
- let whereCondition = eq(users.email, email)
+ let whereCondition = sql`LOWER(${users.email}) = LOWER(${email})`
// 기본적으로 활성 사용자만 조회
if (!includeInactive) {
whereCondition = and(
- eq(users.email, email),
+ // eq(users.email, email),
+ sql`LOWER(${users.email}) = LOWER(${email})`,
eq(users.isActive, true)
)!
}