diff options
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) )! } |
