From 5036cf2908792cef45f06256e71f10920f647f49 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 28 May 2025 19:03:21 +0000 Subject: (김준회) 기술영업 조선 RFQ (SHI/벤더) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../evcp/(evcp)/budgetary-tech-sales-ship/page.tsx | 61 + .../techsales/rfq-offshore-hull/page.tsx | 17 + .../(partners)/techsales/rfq-offshore-top/page.tsx | 17 + .../(partners)/techsales/rfq-ship/[id]/page.tsx | 126 ++ .../(partners)/techsales/rfq-ship/page.tsx | 219 +++ app/not-found.tsx | 285 ++++ components/ui/back-button.tsx | 112 ++ config/menuConfig.ts | 19 + db/schema/index.ts | 1 + db/schema/techSales.ts | 473 ++++++ lib/items-tech/service.ts | 155 ++ lib/techsales-rfq/actions.ts | 59 + lib/techsales-rfq/repository.ts | 380 +++++ lib/techsales-rfq/service.ts | 1540 ++++++++++++++++++++ lib/techsales-rfq/table/README.md | 41 + lib/techsales-rfq/table/create-rfq-dialog.tsx | 537 +++++++ .../table/detail-table/add-vendor-dialog.tsx | 357 +++++ .../table/detail-table/delete-vendor-dialog.tsx | 150 ++ .../table/detail-table/rfq-detail-column.tsx | 291 ++++ .../table/detail-table/rfq-detail-table.tsx | 654 +++++++++ .../table/detail-table/update-vendor-sheet.tsx | 449 ++++++ .../detail-table/vendor-communication-drawer.tsx | 521 +++++++ .../vendor-quotation-comparison-dialog.tsx | 340 +++++ lib/techsales-rfq/table/project-detail-dialog.tsx | 322 ++++ lib/techsales-rfq/table/rfq-filter-sheet.tsx | 759 ++++++++++ lib/techsales-rfq/table/rfq-table-column.tsx | 409 ++++++ .../table/rfq-table-toolbar-actions.tsx | 63 + lib/techsales-rfq/table/rfq-table.tsx | 524 +++++++ lib/techsales-rfq/validations.ts | 119 ++ .../vendor-response/buyer-communication-drawer.tsx | 522 +++++++ .../vendor-response/detail/communication-tab.tsx | 215 +++ .../vendor-response/detail/project-info-tab.tsx | 269 ++++ .../detail/quotation-response-tab.tsx | 382 +++++ .../vendor-response/detail/quotation-tabs.tsx | 118 ++ .../vendor-response/quotation-editor.tsx | 559 +++++++ .../vendor-response/quotation-item-editor.tsx | 664 +++++++++ .../table/vendor-quotations-table-columns.tsx | 365 +++++ .../table/vendor-quotations-table.tsx | 143 ++ lib/utils.ts | 33 + lib/vendors/service.ts | 42 + 40 files changed, 12312 insertions(+) create mode 100644 app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx create mode 100644 app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx create mode 100644 app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx create mode 100644 app/[lng]/partners/(partners)/techsales/rfq-ship/[id]/page.tsx create mode 100644 app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx create mode 100644 app/not-found.tsx create mode 100644 components/ui/back-button.tsx create mode 100644 db/schema/techSales.ts create mode 100644 lib/techsales-rfq/actions.ts create mode 100644 lib/techsales-rfq/repository.ts create mode 100644 lib/techsales-rfq/service.ts create mode 100644 lib/techsales-rfq/table/README.md create mode 100644 lib/techsales-rfq/table/create-rfq-dialog.tsx create mode 100644 lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx create mode 100644 lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx create mode 100644 lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx create mode 100644 lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx create mode 100644 lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx create mode 100644 lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx create mode 100644 lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx create mode 100644 lib/techsales-rfq/table/project-detail-dialog.tsx create mode 100644 lib/techsales-rfq/table/rfq-filter-sheet.tsx create mode 100644 lib/techsales-rfq/table/rfq-table-column.tsx create mode 100644 lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx create mode 100644 lib/techsales-rfq/table/rfq-table.tsx create mode 100644 lib/techsales-rfq/validations.ts create mode 100644 lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx create mode 100644 lib/techsales-rfq/vendor-response/detail/communication-tab.tsx create mode 100644 lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx create mode 100644 lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx create mode 100644 lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx create mode 100644 lib/techsales-rfq/vendor-response/quotation-editor.tsx create mode 100644 lib/techsales-rfq/vendor-response/quotation-item-editor.tsx create mode 100644 lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx create mode 100644 lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx diff --git a/app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx new file mode 100644 index 00000000..05b856e5 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx @@ -0,0 +1,61 @@ +import { searchParamsCache } from "@/lib/techsales-rfq/validations" +import { getTechSalesRfqsWithJoin } from "@/lib/techsales-rfq/service" +import { getValidFilters } from "@/lib/data-table" +import { Shell } from "@/components/shell" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" +import { type SearchParams } from "@/types/table" +import * as React from "react" + +interface RfqPageProps { + searchParams: Promise +} + +export default async function RfqPage(props: RfqPageProps) { + // searchParams를 await하여 resolve + const searchParams = await props.searchParams + + // 파라미터 파싱 + const search = searchParamsCache.parse(searchParams); + const validFilters = getValidFilters(search.filters); + + // 기술영업 조선 RFQ 데이터를 Promise.all로 감싸서 전달 + const promises = Promise.all([ + getTechSalesRfqsWithJoin({ + ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) + filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) + }) + ]) + + return ( + {/* fullscreen variant 사용 */} + {/* 고정 헤더 영역 */} +
+
+
+

+ 기술영업-조선 RFQ +

+
+
+
+ + {/* 테이블 영역 - 남은 공간 모두 차지 */} +
+ + } + > + + +
+
+ ) +} \ No newline at end of file diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx new file mode 100644 index 00000000..40be6773 --- /dev/null +++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx @@ -0,0 +1,17 @@ +import { Shell } from "@/components/shell"; + +export default function TechSalesRfqShipPage() { + return ( + +
+

기술영업 - 해양 Hull/Top RFQ

+

+ 벤더가 해양 Hull/Top RFQ 목록을 확인하고 관리합니다. +

+

+ 기술영업 해양 Hull/Top 은 업무 요구사항이 동일하다면 통합으로 개발될 수 있습니다. +

+
+
+ ); +} \ No newline at end of file diff --git a/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx new file mode 100644 index 00000000..40be6773 --- /dev/null +++ b/app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx @@ -0,0 +1,17 @@ +import { Shell } from "@/components/shell"; + +export default function TechSalesRfqShipPage() { + return ( + +
+

기술영업 - 해양 Hull/Top RFQ

+

+ 벤더가 해양 Hull/Top RFQ 목록을 확인하고 관리합니다. +

+

+ 기술영업 해양 Hull/Top 은 업무 요구사항이 동일하다면 통합으로 개발될 수 있습니다. +

+
+
+ ); +} \ No newline at end of file diff --git a/app/[lng]/partners/(partners)/techsales/rfq-ship/[id]/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-ship/[id]/page.tsx new file mode 100644 index 00000000..d748fc46 --- /dev/null +++ b/app/[lng]/partners/(partners)/techsales/rfq-ship/[id]/page.tsx @@ -0,0 +1,126 @@ +// app/[lng]/partners/(partners)/techsales/rfq-ship/[id]/page.tsx - 기술영업 RFQ 견적 응답 페이지 +import { Metadata } from "next" +import { notFound } from "next/navigation" +import * as React from "react" +import { Shell } from "@/components/shell" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { BackButton } from "@/components/ui/back-button" +import { getTechSalesVendorQuotation } from "@/lib/techsales-rfq/service" +import { formatDate } from "@/lib/utils" +import { TechSalesQuotationTabs } from "@/lib/techsales-rfq/vendor-response/detail/quotation-tabs" + +interface TechSalesVendorQuotationDetailPageProps { + params: Promise<{ + id: string + lng: string + }> + searchParams: Promise<{ + tab?: string + }> +} + +export async function generateMetadata(): Promise { + return { + title: "기술영업 RFQ 견적서 상세", + description: "기술영업 RFQ 견적서 상세 정보 및 응답", + } +} + +export default async function TechSalesVendorQuotationDetailPage({ + params, + searchParams, +}: TechSalesVendorQuotationDetailPageProps) { + const { id } = await params + const { tab } = await searchParams + const quotationId = parseInt(id) + + if (isNaN(quotationId)) { + notFound() + } + + // 견적서 데이터 조회 + const { data: quotation, error } = await getTechSalesVendorQuotation(quotationId) + + if (error || !quotation) { + notFound() + } + + const rfq = quotation.rfq + const vendor = quotation.vendor + + // 상태별 배지 색상 + const getStatusBadgeVariant = (status: string) => { + switch (status) { + case "Draft": + return "secondary" + case "Submitted": + return "default" + case "Revised": + return "outline" + case "Rejected": + return "destructive" + case "Accepted": + return "success" + default: + return "secondary" + } + } + + const getStatusLabel = (status: string) => { + switch (status) { + case "Draft": + return "초안" + case "Submitted": + return "제출됨" + case "Revised": + return "수정됨" + case "Rejected": + return "반려됨" + case "Accepted": + return "승인됨" + default: + return status + } + } + + return ( + + {/* 헤더 */} +
+
+
+

견적서 상세

+ + {getStatusLabel(quotation.status)} + +
+
+ RFQ: {rfq?.rfqCode || "미할당"} + + 벤더: {vendor?.vendorName} + + 마감일: {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"} +
+
+ + {/* 우측 상단 버튼 */} +
+ + 목록으로 돌아가기 + +
+
+ + + + {/* 탭 컨텐츠 */} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx new file mode 100644 index 00000000..5b0ffb61 --- /dev/null +++ b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx @@ -0,0 +1,219 @@ +// app/vendor/quotations/page.tsx +import * as React from "react"; +import Link from "next/link"; +import { Metadata } from "next"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { LogIn } from "lucide-react"; +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; +import { Shell } from "@/components/shell"; +import { getValidFilters } from "@/lib/data-table"; +import { type SearchParams } from "@/types/table"; +import { searchParamsVendorRfqCache } from "@/lib/techsales-rfq/validations"; +import { + TECH_SALES_QUOTATION_STATUSES, + TECH_SALES_QUOTATION_STATUS_CONFIG +} from "@/db/schema"; + +import { getQuotationStatusCounts, getVendorQuotations } from "@/lib/techsales-rfq/service"; +import { VendorQuotationsTable } from "@/lib/techsales-rfq/vendor-response/table/vendor-quotations-table"; + +export const metadata: Metadata = { + title: "기술영업 견적서 관리", + description: "기술영업 RFQ 견적서를 관리합니다.", +}; + +interface VendorQuotationsPageProps { + searchParams: SearchParams; +} + +export default async function VendorQuotationsPage({ + searchParams, +}: VendorQuotationsPageProps) { + // 세션 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + return ( + +
+
+

로그인이 필요합니다

+

+ 견적서를 확인하려면 로그인해주세요. +

+
+ +
+
+ ); + } + + // 벤더 ID 확인 (사용자의 회사 ID가 벤더 ID) + const vendorId = session.user.companyId; + if (!vendorId) { + return ( + +
+
+

회사 정보가 없습니다

+

+ 견적서를 확인하려면 회사 정보가 필요합니다. +

+
+
+
+ ); + } + + // 검색 파라미터 파싱 및 검증 + const search = searchParamsVendorRfqCache.parse(searchParams); + const validFilters = getValidFilters(search.filters); + + // 견적서 상태별 개수 조회 + const statusCountsPromise = getQuotationStatusCounts(vendorId.toString()); + + // 견적서 목록 조회 + const quotationsPromise = getVendorQuotations( + { + flags: search.flags, + page: search.page, + perPage: search.perPage, + sort: search.sort, + filters: validFilters, + joinOperator: search.joinOperator, + search: search.search, + from: search.from, + to: search.to, + }, + vendorId.toString() + ); + + return ( + + {/* 고정 헤더 영역 */} +
+
+
+

기술영업 견적서

+

+ 할당받은 RFQ에 대한 견적서를 작성하고 관리합니다. +

+
+
+ + {/* 상태별 개수 카드 */} +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( + + + 로딩중... + + +
-
+
+
+ ))} +
+
+ } + > + + +
+ + {/* 견적서 테이블 */} +
+ + } + > +
+ ({ data: result.data, pageCount: result.pageCount }))])} /> +
+
+
+ +
+ ); +} + +// 상태별 개수 카드 컴포넌트 +async function StatusCards({ + statusCountsPromise, +}: { + statusCountsPromise: Promise<{ + data: { status: string; count: number }[] | null; + error: string | null; + }>; +}) { + const { data: statusCounts, error } = await statusCountsPromise; + + if (error || !statusCounts) { + return ( +
+
+ + + 오류 + + +
-
+

+ 데이터를 불러올 수 없습니다 +

+
+
+
+
+ ); + } + + // 중앙화된 상태 설정 사용 + const statusEntries = Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({ + key: statusValue, + ...TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue] + })); + + console.log(statusCounts, "statusCounts") + + return ( +
+
+ {statusEntries.map((status) => ( + + + {status.label} + + +
+ {statusCounts.find(item => item.status === status.key)?.count || 0} +
+

+ {status.description} +

+
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 00000000..b966d978 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,285 @@ +'use client' + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Separator } from "@/components/ui/separator" +import { ArrowLeft, Building2, Users, Info, AlertCircle, Copy } from "lucide-react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useState, useEffect } from "react" +import { toast } from "sonner" + +// 에러 정보 타입 +interface ErrorInfo { + timestamp: string; + url: string; + userAgent: string; + platform: string; + language: string; + cookieEnabled: boolean; + onLine: boolean; + screen: { + width: number; + height: number; + colorDepth: number; + }; + viewport: { + width: number; + height: number; + }; + referrer: string; + localStorage: boolean; + sessionStorage: boolean; +} + +export default function NotFound() { + const router = useRouter() + const [errorInfo, setErrorInfo] = useState(null) + + useEffect(() => { + // 브라우저 환경 정보 수집 + const collectErrorInfo = () => { + const info: ErrorInfo = { + timestamp: new Date().toISOString(), + url: window.location.href, + userAgent: navigator.userAgent, + platform: navigator.platform, + language: navigator.language, + cookieEnabled: navigator.cookieEnabled, + onLine: navigator.onLine, + screen: { + width: screen.width, + height: screen.height, + colorDepth: screen.colorDepth, + }, + viewport: { + width: window.innerWidth, + height: window.innerHeight, + }, + referrer: document.referrer || '직접 접근', + localStorage: typeof Storage !== 'undefined', + sessionStorage: typeof Storage !== 'undefined', + } + setErrorInfo(info) + } + + collectErrorInfo() + }, []) + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + toast.success("클립보드에 복사되었습니다") + }).catch(() => { + toast.error("복사에 실패했습니다") + }) + } + + const copyErrorInfo = () => { + if (!errorInfo) return + + const errorText = ` +=== 404 에러 정보 === +시간: ${errorInfo.timestamp} +URL: ${errorInfo.url} +리퍼러: ${errorInfo.referrer} +브라우저: ${errorInfo.userAgent} +플랫폼: ${errorInfo.platform} +언어: ${errorInfo.language} +화면 해상도: ${errorInfo.screen.width}x${errorInfo.screen.height} +뷰포트: ${errorInfo.viewport.width}x${errorInfo.viewport.height} +온라인 상태: ${errorInfo.onLine ? '온라인' : '오프라인'} +쿠키 활성화: ${errorInfo.cookieEnabled ? '예' : '아니오'} + `.trim() + + copyToClipboard(errorText) + } + + return ( +
+ + +
+ 404 +
+ + 이 페이지는 개발중이거나, 잘못된 URL 입니다. + + + THIS PAGE IS UNDER DEVELOPMENT OR INVALID URL. + +
+ + + + +
+ 아래 버튼을 통해 원하는 페이지로 이동하세요 +
+ +
+ {/* SHI 사용자 홈 */} + + + {/* 벤더 홈 */} + + + {/* 뒤로 가기 */} + +
+ + + +
+ + + {/* 에러 정보 다이얼로그 */} + + + + + + + + + 에러 상세 정보 + + + 기술 지원을 위한 상세 환경 정보입니다. + + + + {errorInfo && ( +
+
+ +
+ +
+
+ +
+ {errorInfo.timestamp} +
+
+ +
+ +
+ {errorInfo.url} +
+
+ +
+ +
+ {errorInfo.referrer} +
+
+ +
+ +
+ {errorInfo.userAgent} +
+
+ +
+
+ +
+ {errorInfo.platform} +
+
+ +
+ +
+ {errorInfo.language} +
+
+
+ +
+
+ +
+ {errorInfo.screen.width} × {errorInfo.screen.height} +
+
+ +
+ +
+ {errorInfo.viewport.width} × {errorInfo.viewport.height} +
+
+
+ +
+
+ +
+ {errorInfo.onLine ? '온라인' : '오프라인'} +
+
+ +
+ +
+ {errorInfo.cookieEnabled ? '예' : '아니오'} +
+
+ +
+ +
+ {errorInfo.screen.colorDepth}bit +
+
+
+
+
+ )} +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/ui/back-button.tsx b/components/ui/back-button.tsx new file mode 100644 index 00000000..364c0b96 --- /dev/null +++ b/components/ui/back-button.tsx @@ -0,0 +1,112 @@ +"use client" + +import * as React from "react" +import { useRouter, usePathname } from "next/navigation" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +/** + * BackButton 컴포넌트 - 세그먼트를 동적으로 제거하여 상위 경로로 이동 + * + * 사용 예시: + * + * // 기본 사용 (1개 세그먼트 제거) + * 목록으로 + * + * // 2개 세그먼트 제거 (/a/b/c/d -> /a/b) + * 상위 목록으로 + * + * // 커스텀 경로로 이동 + * 대시보드로 + * + * // 아이콘 없이 사용 + * 돌아가기 + * + * // 커스텀 스타일링 + * + * 이전 페이지 + * + */ + +interface BackButtonProps extends React.ComponentPropsWithoutRef { + /** + * 제거할 세그먼트 개수 (기본값: 1) + * 예: segmentsToRemove=1이면 /a/b/c -> /a/b + * segmentsToRemove=2이면 /a/b/c -> /a + */ + segmentsToRemove?: number + + /** + * 버튼에 표시할 텍스트 (기본값: "목록으로") + */ + children?: React.ReactNode + + /** + * 아이콘을 표시할지 여부 (기본값: true) + */ + showIcon?: boolean + + /** + * 커스텀 경로를 지정할 경우 (segmentsToRemove 대신 사용) + */ + customPath?: string +} + +export const BackButton = React.forwardRef< + React.ElementRef, + BackButtonProps +>(({ + segmentsToRemove = 1, + children = "Go Back", + showIcon = true, + customPath, + className, + onClick, + ...props +}, ref) => { + const router = useRouter() + const pathname = usePathname() + + const handleClick = React.useCallback((event: React.MouseEvent) => { + // 커스텀 onClick이 있으면 먼저 실행 + if (onClick) { + onClick(event) + // preventDefault가 호출되었으면 기본 동작 중단 + if (event.defaultPrevented) { + return + } + } + + let targetPath: string + + if (customPath) { + targetPath = customPath + } else { + // 현재 경로에서 세그먼트 제거 + const segments = pathname.split('/').filter(Boolean) + const newSegments = segments.slice(0, -segmentsToRemove) + targetPath = newSegments.length > 0 ? `/${newSegments.join('/')}` : '/' + } + + router.push(targetPath) + }, [router, pathname, segmentsToRemove, customPath, onClick]) + + return ( + + ) +}) + +BackButton.displayName = "BackButton" \ No newline at end of file diff --git a/config/menuConfig.ts b/config/menuConfig.ts index f1f5af63..eb6913c3 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -304,12 +304,31 @@ export const mainNavVendor: MenuSection[] = [ { title: "구매 관리", + useGrouping: true, items: [ { title: "기본 계약 서명", href: `/partners/basic-contract`, description: "기본 계약서 및 관련 문서에 대한 서명", }, + { + title: "기술영업 - 조선 RFQ", + href: `/partners/techsales/rfq-ship`, + description: "견적 요청에 대한 응답 작성", + group: "기술영업" + }, + { + title: "기술영업 - 해양 Hull RFQ", + href: `/partners/techsales/rfq-offshore-hull`, + description: "견적 요청에 대한 응답 작성", + group: "기술영업" + }, + { + title: "기술영업 - 해양 Top RFQ", + href: `/partners/techsales/rfq-offshore-top`, + description: "견적 요청에 대한 응답 작성", + group: "기술영업" + }, { title: "RFQ", href: `/partners/rfq`, diff --git a/db/schema/index.ts b/db/schema/index.ts index fdd73344..309af050 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -14,3 +14,4 @@ export * from './logs'; export * from './basicContractDocumnet'; export * from './procurementRFQ'; export * from './setting'; +export * from './techSales'; \ No newline at end of file diff --git a/db/schema/techSales.ts b/db/schema/techSales.ts new file mode 100644 index 00000000..590ddc76 --- /dev/null +++ b/db/schema/techSales.ts @@ -0,0 +1,473 @@ +/** + * 기술영업 조선 및 해양을 위한 스키마 관련 설명 + * + * 배경지식: + * 기술영업 조선은 TBE, CBE 없이 RFQ로 업무가 종결됩니다. + * 기술영업 해양은 RFQ,TBE, CBE 진행를 모두 진행해 업무를 종결합니다. + * + * 기술영업은 projects.ts 스키마의 biddingProjects 테이블을 참조하고, 기술영업의 아이템리스트를 참조하여 RFQ를 자동생성합니다. + * 기술영업 RFQ 추가는 버튼 >> 모달로 진행합니다. + * 모달에서 견적프로젝트를 선택하면, 해당하는 자재그룹들을 리스팅합니다. + * 각 자재그룹별로 RFQ 여러 개를 한번에 생성할 수 있습니다. (전체 선택하고 만들면 자동 생성과 동일한 기능) + * + * 벤더가 토탈 가격을 산정하는데 필요한 프로젝트 관련 정보는 벤더에게 제공됩니다. (각 시리즈별 K/L 일정을 제공) + * 구매와 달리 지불조건, 인코텀즈, 배송지 등의 세부적인 정보는 벤더에게 제공되지 않습니다. + * + * 즉, 벤더는 대략적인 일정과 척수, 선종, 자재 이름만 보고 가격을 어림잡아 산정하는 것입니다. + * 이 가격은 내부적으로 사용되며, 구매 분과로는 '자재그룹코드'를 기준으로 이어질 수 있습니다. + * + * 기준정보인 견적프로젝트 정보가 변경되면 RFQ도 변경되어야 하는가? -> 아니다. 별도로 저장하게 해달라. (장민욱 프로) + * + */ + +// 기술영업 RFQ는 별도의 PR 아이템 테이블을 사용하지 않음. +// 해당 자재그룹에 대해 프로젝트 스펙만 보고 토탈가를 리턴하는 방식임 (선종, 척수, 자재 이름 등 프로젝트 정보와 자재 정보만 보고 가격을 어림잡아 산정하는 것) + +import { + foreignKey, + pgTable, + serial, + varchar, + text, + timestamp, + boolean, + integer, + numeric, + date, + jsonb, +} from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { biddingProjects } from "./projects"; +import { users } from "./users"; +import { items } from "./items"; +import { vendors } from "./vendors"; + +// ===== 기술영업 상태 관리 상수 및 타입 ===== + +// 기술영업 RFQ 상태 +export const TECH_SALES_RFQ_STATUSES = { + RFQ_CREATED: "RFQ Created", + RFQ_VENDOR_ASSIGNED: "RFQ Vendor Assignned", + RFQ_SENT: "RFQ Sent", + QUOTATION_ANALYSIS: "Quotation Analysis", + CLOSED: "Closed", +} as const; + +export type TechSalesRfqStatus = typeof TECH_SALES_RFQ_STATUSES[keyof typeof TECH_SALES_RFQ_STATUSES]; + +// 기술영업 벤더 견적서 상태 +export const TECH_SALES_QUOTATION_STATUSES = { + DRAFT: "Draft", + SUBMITTED: "Submitted", + REVISED: "Revised", + REJECTED: "Rejected", + ACCEPTED: "Accepted", +} as const; + +export type TechSalesQuotationStatus = typeof TECH_SALES_QUOTATION_STATUSES[keyof typeof TECH_SALES_QUOTATION_STATUSES]; + +// 상태 설정 객체 (UI에서 사용) +export const TECH_SALES_QUOTATION_STATUS_CONFIG = { + [TECH_SALES_QUOTATION_STATUSES.DRAFT]: { + label: "초안", + variant: "secondary" as const, + description: "작성 중인 견적서", + color: "text-yellow-600", + }, + [TECH_SALES_QUOTATION_STATUSES.SUBMITTED]: { + label: "제출됨", + variant: "default" as const, + description: "제출된 견적서", + color: "text-blue-600", + }, + [TECH_SALES_QUOTATION_STATUSES.REVISED]: { + label: "수정됨", + variant: "outline" as const, + description: "수정된 견적서", + color: "text-purple-600", + }, + [TECH_SALES_QUOTATION_STATUSES.REJECTED]: { + label: "반려됨", + variant: "destructive" as const, + description: "반려된 견적서", + color: "text-red-600", + }, + [TECH_SALES_QUOTATION_STATUSES.ACCEPTED]: { + label: "승인됨", + variant: "success" as const, + description: "승인된 견적서", + color: "text-green-600", + }, +} as const; + +// ===== 스키마 정의 ===== + +// 기술영업 RFQ 테이블 +export const techSalesRfqs = pgTable("tech_sales_rfqs", { + id: serial("id").primaryKey(), + rfqCode: varchar("rfq_code", { length: 50 }).unique(), // ex) "RFQ-2025-001" + + // item에서 기술영업에서 사용하는 추가 정보는 itemShipbuilding 테이블에 저장되어 있다. + itemId: integer("item_id") + .notNull() + .references(() => items.id, { onDelete: "cascade" }), + + // 프로젝트 참조 ID + biddingProjectId: integer("bidding_project_id").references(() => biddingProjects.id, { onDelete: "set null" }), + + // 기술영업에서 벤더에게 제공할 정보로, 모든 벤더에게 동일하게 제공함. + materialCode: varchar("material_code", { length: 255 }), + + // 벤더별로 보내는 날짜는 다르지만, 이 업무를 언제까지 처리하겠다는 의미의 dueDate + dueDate: date("due_date", { mode: "date" }).$type().notNull(), + + rfqSendDate: date("rfq_send_date", { mode: "date" }).$type(), + status: varchar("status", { length: 30 }) + .$type() + .default(TECH_SALES_RFQ_STATUSES.RFQ_CREATED) + .notNull(), + + // rfq 밀봉 기능은, 기술영업에서 사용하지 않겠다고 함. + + //picCode: 발주자 코드 + picCode: varchar("pic_code", { length: 50 }), + remark: text("remark"), + + // WHO + sentBy: integer("sent_by").references(() => users.id, { + onDelete: "set null", + }), + createdBy: integer("created_by") + .notNull() + .references(() => users.id, { onDelete: "set null" }), + updatedBy: integer("updated_by") + .notNull() + .references(() => users.id, { onDelete: "set null" }), + + // WHEN + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + + // 삼성중공업이 RFQ를 취소한 경우 + cancelReason: text("cancel_reason"), + + // 프로젝트 정보 스냅샷 (프로젝트 관련 모든 정보) + // 기존 개별 컬럼 방식에서 jsonb로 마이그레이션 시: + // 1. 기존 RFQ 데이터는 pspid 등의 개별 컬럼 값을 기반으로 jsonb 형태로 변환하여 마이그레이션 + // 2. 새로운 RFQ 생성 시에는 biddingProjects와 projectSeries 테이블에서 정보를 조회하여 스냅샷으로 저장 + projectSnapshot: jsonb("project_snapshot").$type<{ + pspid: string; // 견적프로젝트번호 + projNm?: string; // 견적프로젝트명 + sector?: string; // 부문(S / M) + projMsrm?: number; // 척수 + kunnr?: string; // 선주코드 + kunnrNm?: string; // 선주명 + cls1?: string; // 선급코드 + cls1Nm?: string; // 선급명 + ptype?: string; // 선종코드 + ptypeNm?: string; // 선종명 + pmodelCd?: string; // 선형코드 + pmodelNm?: string; // 선형명 + pmodelSz?: string; // 선형크기 + pmodelUom?: string; // 선형단위 + txt04?: string; // 견적상태코드 + txt30?: string; // 견적상태명 + estmPm?: string; // 견적대표PM 성명 + pspCreatedAt?: Date | string; // 원래 생성 일자 + pspUpdatedAt?: Date | string; // 원래 업데이트 일자 + }>(), + + // 프로젝트 시리즈 정보 스냅샷 + // 시리즈 정보는 배열 형태로 저장되며, 프로젝트의 모든 시리즈 정보를 포함 + // RFQ 생성 시점의 시리즈 정보를 스냅샷으로 보존함으로써 후속 변경에 영향을 받지 않음 + seriesSnapshot: jsonb("series_snapshot").$type>(), +}); + +// 기술영업 첨부파일 테이블 (RFQ 에 첨부되는 것임) +export const techSalesAttachments = pgTable( + "tech_sales_attachments", + { + id: serial("id").primaryKey(), + attachmentType: varchar("attachment_type", { length: 50 }).notNull(), // 'RFQ_COMMON', 'VENDOR_SPECIFIC' + techSalesRfqId: integer("tech_sales_rfq_id").references( + () => techSalesRfqs.id, + { onDelete: "cascade" } + ), + fileName: varchar("file_name", { length: 255 }).notNull(), + originalFileName: varchar("original_file_name", { length: 255 }).notNull(), + filePath: varchar("file_path", { length: 512 }).notNull(), + fileSize: integer("file_size"), + fileType: varchar("file_type", { length: 100 }), + description: varchar("description", { length: 500 }), + createdBy: integer("created_by") + .references(() => users.id, { onDelete: "set null" }) + .notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, +); + +// 기술영업 벤더 견적서(응답) 테이블 (핵심: Total Price, Currency, Validity) 토탈가격, 통화, 유효기간 +export const techSalesVendorQuotations = pgTable( + "tech_sales_vendor_quotations", + { + id: serial("id").primaryKey(), + rfqId: integer("rfq_id") + .notNull() + .references(() => techSalesRfqs.id, { onDelete: "cascade" }), + vendorId: integer("vendor_id") + .notNull() + .references(() => vendors.id, { onDelete: "set null" }), + + // === [시작]견적 응답 정보 === + quotationCode: varchar("quotation_code", { length: 50 }), + quotationVersion: integer("quotation_version").default(1), + totalPrice: numeric("total_price"), + currency: varchar("currency", { length: 10 }), + + // 견적 유효 기간 + validUntil: date("valid_until", { mode: "date" }).$type(), + + // === [끝] 견적 응답 정보 === + // 상태 관리 + status: varchar("status", { length: 30 }) + .$type() + .default(TECH_SALES_QUOTATION_STATUSES.DRAFT) + .notNull(), + + // 기타 정보 + remark: text("remark"), + rejectionReason: text("rejection_reason"), + submittedAt: timestamp("submitted_at"), + acceptedAt: timestamp("accepted_at"), + + // 감사 필드 + createdBy: integer("created_by"), + updatedBy: integer("updated_by"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + } +); + +export const techSalesRfqComments = pgTable( + "tech_sales_rfq_comments", + { + id: serial("id").primaryKey(), + rfqId: integer("rfq_id") + .notNull() + .references(() => techSalesRfqs.id, { onDelete: "cascade" }), + vendorId: integer("vendor_id").references(() => vendors.id, { + onDelete: "set null", + }), + userId: integer("user_id").references(() => users.id, { + onDelete: "set null", + }), + content: text("content").notNull(), + isVendorComment: boolean("is_vendor_comment").default(false), + isRead: boolean("is_read").default(false), // 읽음 상태 추가 + parentCommentId: integer("parent_comment_id"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + // 자기참조 FK 정의 + (table) => { + return { + parentFk: foreignKey({ + columns: [table.parentCommentId], + foreignColumns: [table.id], + }).onDelete("set null"), + }; + } +); + +// 코멘트 파일 첨부 +export const techSalesRfqCommentAttachments = pgTable("tech_sales_rfq_comment_attachments", { + id: serial("id").primaryKey(), + rfqId: integer("rfq_id") + .notNull() + .references(() => techSalesRfqs.id, { onDelete: "cascade" }), + commentId: integer("comment_id").references(() => techSalesRfqComments.id, { + onDelete: "cascade", + }), + quotationId: integer("quotation_id").references( + () => techSalesVendorQuotations.id, + { onDelete: "cascade" } + ), + fileName: varchar("file_name", { length: 255 }).notNull(), + fileSize: integer("file_size").notNull(), + fileType: varchar("file_type", { length: 100 }), + filePath: varchar("file_path", { length: 500 }).notNull(), + isVendorUpload: boolean("is_vendor_upload").default(false), + uploadedBy: integer("uploaded_by").references(() => users.id, { + onDelete: "set null", + }), + vendorId: integer("vendor_id").references(() => vendors.id, { + onDelete: "set null", + }), + uploadedAt: timestamp("uploaded_at").defaultNow().notNull(), +}); + + +// 타입 정의 +export type TechSalesVendorQuotations = + typeof techSalesVendorQuotations.$inferSelect; + +// Relations 정의 +export const techSalesRfqsRelations = relations(techSalesRfqs, ({ one, many }) => ({ + // 아이템 관계 + item: one(items, { + fields: [techSalesRfqs.itemId], + references: [items.id], + }), + + // 프로젝트 관계 + biddingProject: one(biddingProjects, { + fields: [techSalesRfqs.biddingProjectId], + references: [biddingProjects.id], + }), + + // 사용자 관계 + createdByUser: one(users, { + fields: [techSalesRfqs.createdBy], + references: [users.id], + relationName: "techSalesRfqCreatedBy", + }), + updatedByUser: one(users, { + fields: [techSalesRfqs.updatedBy], + references: [users.id], + relationName: "techSalesRfqUpdatedBy", + }), + sentByUser: one(users, { + fields: [techSalesRfqs.sentBy], + references: [users.id], + relationName: "techSalesRfqSentBy", + }), + + // 하위 관계들 + vendorQuotations: many(techSalesVendorQuotations), + attachments: many(techSalesAttachments), + comments: many(techSalesRfqComments), +})); + +export const techSalesVendorQuotationsRelations = relations(techSalesVendorQuotations, ({ one, many }) => ({ + // 상위 RFQ 관계 + rfq: one(techSalesRfqs, { + fields: [techSalesVendorQuotations.rfqId], + references: [techSalesRfqs.id], + }), + + // 벤더 관계 + vendor: one(vendors, { + fields: [techSalesVendorQuotations.vendorId], + references: [vendors.id], + }), + + // 사용자 관계 + createdByUser: one(users, { + fields: [techSalesVendorQuotations.createdBy], + references: [users.id], + relationName: "techSalesQuotationCreatedBy", + }), + updatedByUser: one(users, { + fields: [techSalesVendorQuotations.updatedBy], + references: [users.id], + relationName: "techSalesQuotationUpdatedBy", + }), + + // 첨부파일 관계 + attachments: many(techSalesRfqCommentAttachments), +})); + +export const techSalesAttachmentsRelations = relations(techSalesAttachments, ({ one }) => ({ + // 상위 RFQ 관계 + rfq: one(techSalesRfqs, { + fields: [techSalesAttachments.techSalesRfqId], + references: [techSalesRfqs.id], + }), + + // 생성자 관계 + createdByUser: one(users, { + fields: [techSalesAttachments.createdBy], + references: [users.id], + relationName: "techSalesAttachmentCreatedBy", + }), +})); + +export const techSalesRfqCommentsRelations = relations(techSalesRfqComments, ({ one, many }) => ({ + // 상위 RFQ 관계 + rfq: one(techSalesRfqs, { + fields: [techSalesRfqComments.rfqId], + references: [techSalesRfqs.id], + }), + + // 벤더 관계 + vendor: one(vendors, { + fields: [techSalesRfqComments.vendorId], + references: [vendors.id], + }), + + // 사용자 관계 + user: one(users, { + fields: [techSalesRfqComments.userId], + references: [users.id], + relationName: "techSalesCommentUser", + }), + + // 부모 댓글 관계 (자기참조) + parentComment: one(techSalesRfqComments, { + fields: [techSalesRfqComments.parentCommentId], + references: [techSalesRfqComments.id], + relationName: "techSalesCommentParent", + }), + + // 자식 댓글들 + childComments: many(techSalesRfqComments, { + relationName: "techSalesCommentParent", + }), + + // 첨부파일 관계 + attachments: many(techSalesRfqCommentAttachments), +})); + +export const techSalesRfqCommentAttachmentsRelations = relations(techSalesRfqCommentAttachments, ({ one }) => ({ + // 상위 RFQ 관계 + rfq: one(techSalesRfqs, { + fields: [techSalesRfqCommentAttachments.rfqId], + references: [techSalesRfqs.id], + }), + + // 댓글 관계 + comment: one(techSalesRfqComments, { + fields: [techSalesRfqCommentAttachments.commentId], + references: [techSalesRfqComments.id], + }), + + // 견적서 관계 + quotation: one(techSalesVendorQuotations, { + fields: [techSalesRfqCommentAttachments.quotationId], + references: [techSalesVendorQuotations.id], + }), + + // 업로드한 사용자 관계 + uploadedByUser: one(users, { + fields: [techSalesRfqCommentAttachments.uploadedBy], + references: [users.id], + relationName: "techSalesCommentAttachmentUploadedBy", + }), + + // 벤더 관계 + vendor: one(vendors, { + fields: [techSalesRfqCommentAttachments.vendorId], + references: [vendors.id], + }), +})); \ No newline at end of file diff --git a/lib/items-tech/service.ts b/lib/items-tech/service.ts index 70c664f3..ee2c718d 100644 --- a/lib/items-tech/service.ts +++ b/lib/items-tech/service.ts @@ -1008,3 +1008,158 @@ export async function getAllOffshoreItems(): Promise<(ItemOffshoreHull | ItemOff throw new Error("Failed to get items"); } } + + +// ----------------------------------------------------------- +// 기술영업을 위한 로직 +// ----------------------------------------------------------- + +// 조선 공종 타입 +export type WorkType = '기장' | '전장' | '선실' | '배관' | '철의' + +// 조선 아이템 with 공종 정보 +export interface ShipbuildingItem { + id: number + itemCode: string + itemName: string + description: string | null + workType: WorkType + itemList: string | null // 실제 아이템명 + shipTypes: string + createdAt: Date + updatedAt: Date +} + +// 공종별 아이템 조회 +export async function getShipbuildingItemsByWorkType(workType?: WorkType) { + try { + const query = db + .select({ + id: itemShipbuilding.id, + itemCode: itemShipbuilding.itemCode, + itemName: items.itemName, + description: items.description, + workType: itemShipbuilding.workType, + itemList: itemShipbuilding.itemList, + shipTypes: itemShipbuilding.shipTypes, + createdAt: itemShipbuilding.createdAt, + updatedAt: itemShipbuilding.updatedAt, + }) + .from(itemShipbuilding) + .leftJoin(items, eq(itemShipbuilding.itemCode, items.itemCode)) + + if (workType) { + query.where(eq(itemShipbuilding.workType, workType)) + } + + const result = await query + + return { + data: result as ShipbuildingItem[], + error: null + } + } catch (error) { + console.error("조선 아이템 조회 오류:", error) + return { + data: null, + error: error instanceof Error ? error.message : "알 수 없는 오류" + } + } +} + +// 아이템 검색 +export async function searchShipbuildingItems(searchQuery: string, workType?: WorkType) { + try { + const searchConditions = [ + ilike(itemShipbuilding.itemCode, `%${searchQuery}%`), + ilike(items.itemName, `%${searchQuery}%`), + ilike(items.description, `%${searchQuery}%`), + ilike(itemShipbuilding.itemList, `%${searchQuery}%`) + ] + + let whereCondition = or(...searchConditions) + + if (workType) { + whereCondition = and( + eq(itemShipbuilding.workType, workType), + or(...searchConditions) + ) + } + + const result = await db + .select({ + id: itemShipbuilding.id, + itemCode: itemShipbuilding.itemCode, + itemName: items.itemName, + description: items.description, + workType: itemShipbuilding.workType, + itemList: itemShipbuilding.itemList, + shipTypes: itemShipbuilding.shipTypes, + createdAt: itemShipbuilding.createdAt, + updatedAt: itemShipbuilding.updatedAt, + }) + .from(itemShipbuilding) + .leftJoin(items, eq(itemShipbuilding.itemCode, items.itemCode)) + .where(whereCondition) + + return { + data: result as ShipbuildingItem[], + error: null + } + } catch (error) { + console.error("조선 아이템 검색 오류:", error) + return { + data: null, + error: error instanceof Error ? error.message : "알 수 없는 오류" + } + } +} + +// 모든 공종 목록 조회 +export async function getWorkTypes() { + return [ + { code: '기장' as WorkType, name: '기장품', description: '기계 장치 및 엔진' }, + { code: '전장' as WorkType, name: '전장품', description: '전기 장치 및 제어 시스템' }, + { code: '선실' as WorkType, name: '선실품', description: '선실 및 거주 구역' }, + { code: '배관' as WorkType, name: '배관품', description: '배관 및 파이프 시스템' }, + { code: '철의' as WorkType, name: '철의품', description: '선체 구조물 및 강재' }, + ] +} + +// 특정 아이템 코드들로 아이템 조회 +export async function getShipbuildingItemsByCodes(itemCodes: string[]) { + try { + const result = await db + .select({ + id: itemShipbuilding.id, + itemCode: itemShipbuilding.itemCode, + itemName: items.itemName, + description: items.description, + workType: itemShipbuilding.workType, + itemList: itemShipbuilding.itemList, + shipTypes: itemShipbuilding.shipTypes, + createdAt: itemShipbuilding.createdAt, + updatedAt: itemShipbuilding.updatedAt, + }) + .from(itemShipbuilding) + .leftJoin(items, eq(itemShipbuilding.itemCode, items.itemCode)) + .where( + or(...itemCodes.map(code => eq(itemShipbuilding.itemCode, code))) + ) + + return { + data: result as ShipbuildingItem[], + error: null + } + } catch (error) { + console.error("조선 아이템 코드별 조회 오류:", error) + return { + data: null, + error: error instanceof Error ? error.message : "알 수 없는 오류" + } + } +} + +// ----------------------------------------------------------- +// 기술영업을 위한 로직 끝 +// ----------------------------------------------------------- \ No newline at end of file diff --git a/lib/techsales-rfq/actions.ts b/lib/techsales-rfq/actions.ts new file mode 100644 index 00000000..9bcb20e5 --- /dev/null +++ b/lib/techsales-rfq/actions.ts @@ -0,0 +1,59 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { + acceptTechSalesVendorQuotation, + rejectTechSalesVendorQuotation +} from "./service" + +// ... existing code ... + +/** + * 기술영업 벤더 견적 승인 (벤더 선택) Server Action + */ +export async function acceptTechSalesVendorQuotationAction(quotationId: number) { + try { + const result = await acceptTechSalesVendorQuotation(quotationId) + + if (result.success) { + // 관련 페이지들 재검증 + revalidatePath("/evcp/budgetary-tech-sales-ship") + revalidatePath("/partners/techsales") + + return { success: true, message: "벤더가 성공적으로 선택되었습니다" } + } else { + return { success: false, error: result.error } + } + } catch (error) { + console.error("벤더 선택 액션 오류:", error) + return { + success: false, + error: error instanceof Error ? error.message : "벤더 선택에 실패했습니다" + } + } +} + +/** + * 기술영업 벤더 견적 거절 Server Action + */ +export async function rejectTechSalesVendorQuotationAction(quotationId: number, rejectionReason?: string) { + try { + const result = await rejectTechSalesVendorQuotation(quotationId, rejectionReason) + + if (result.success) { + // 관련 페이지들 재검증 + revalidatePath("/evcp/budgetary-tech-sales-ship") + revalidatePath("/partners/techsales") + + return { success: true, message: "견적이 성공적으로 거절되었습니다" } + } else { + return { success: false, error: result.error } + } + } catch (error) { + console.error("견적 거절 액션 오류:", error) + return { + success: false, + error: error instanceof Error ? error.message : "견적 거절에 실패했습니다" + } + } +} \ No newline at end of file diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts new file mode 100644 index 00000000..260eef19 --- /dev/null +++ b/lib/techsales-rfq/repository.ts @@ -0,0 +1,380 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + techSalesRfqs, + techSalesVendorQuotations, + items, + vendors, + users +} from "@/db/schema"; +import { + asc, + desc, count, SQL, sql +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + + +export type NewTechSalesRfq = typeof techSalesRfqs.$inferInsert; +/** + * 기술영업 RFQ 생성 + * ID 및 생성일 리턴 + */ +export async function insertTechSalesRfq( + tx: PgTransaction, + data: NewTechSalesRfq +) { + return tx + .insert(techSalesRfqs) + .values(data) + .returning({ id: techSalesRfqs.id, createdAt: techSalesRfqs.createdAt }); +} + +/** + * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 + * - 트랜잭션(tx)을 받아서 사용하도록 구현 + */ +export async function selectTechSalesRfqs( + tx: PgTransaction, + params: { + where?: any; + orderBy?: (ReturnType | ReturnType)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(techSalesRfqs) + .where(where ?? undefined) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countTechSalesRfqs( + tx: PgTransaction, + where?: any +) { + const res = await tx.select({ count: count() }).from(techSalesRfqs).where(where); + return res[0]?.count ?? 0; +} + +/** + * RFQ 정보 직접 조인 조회 (뷰 대신 테이블 조인 사용) + */ +export async function selectTechSalesRfqsWithJoin( + tx: PgTransaction, + params: { + where?: any; + orderBy?: (ReturnType | ReturnType | SQL)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행 + const query = tx.select({ + // RFQ 기본 정보 + id: techSalesRfqs.id, + rfqCode: techSalesRfqs.rfqCode, + itemId: techSalesRfqs.itemId, + itemName: items.itemName, + materialCode: techSalesRfqs.materialCode, + + // 날짜 및 상태 정보 + dueDate: techSalesRfqs.dueDate, + rfqSendDate: techSalesRfqs.rfqSendDate, + status: techSalesRfqs.status, + + // 담당자 및 비고 + picCode: techSalesRfqs.picCode, + remark: techSalesRfqs.remark, + cancelReason: techSalesRfqs.cancelReason, + + // 생성/수정 정보 + createdAt: techSalesRfqs.createdAt, + updatedAt: techSalesRfqs.updatedAt, + + // 사용자 정보 + createdBy: techSalesRfqs.createdBy, + createdByName: sql`created_user.name`, + updatedBy: techSalesRfqs.updatedBy, + updatedByName: sql`updated_user.name`, + sentBy: techSalesRfqs.sentBy, + sentByName: sql`sent_user.name`, + + // 프로젝트 정보 (스냅샷) + projectSnapshot: techSalesRfqs.projectSnapshot, + seriesSnapshot: techSalesRfqs.seriesSnapshot, + + // 프로젝트 핵심 정보 + pspid: sql`${techSalesRfqs.projectSnapshot}->>'pspid'`, + projNm: sql`${techSalesRfqs.projectSnapshot}->>'projNm'`, + sector: sql`${techSalesRfqs.projectSnapshot}->>'sector'`, + projMsrm: sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`, + ptypeNm: sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`, + + // 첨부파일 개수 + attachmentCount: sql`( + SELECT COUNT(*) + FROM tech_sales_attachments + WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} + )`, + + // 벤더 견적 개수 + quotationCount: sql`( + SELECT COUNT(*) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + )`, + }) + .from(techSalesRfqs) + .leftJoin(items, sql`${techSalesRfqs.itemId} = ${items.id}`) + .leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`) + .leftJoin(sql`${users} AS updated_user`, sql`${techSalesRfqs.updatedBy} = updated_user.id`) + .leftJoin(sql`${users} AS sent_user`, sql`${techSalesRfqs.sentBy} = sent_user.id`); + + // where 조건 적용 + const queryWithWhere = where ? query.where(where) : query; + + // orderBy 적용 + const queryWithOrderBy = orderBy?.length + ? queryWithWhere.orderBy(...orderBy) + : queryWithWhere.orderBy(desc(techSalesRfqs.createdAt)); + + // offset과 limit 적용 후 실행 + return queryWithOrderBy.offset(offset).limit(limit); +} + +/** + * RFQ 개수 직접 조회 (뷰 대신 테이블 조인 사용) + */ +export async function countTechSalesRfqsWithJoin( + tx: PgTransaction, + where?: any +) { + const res = await tx + .select({ count: count() }) + .from(techSalesRfqs) + .leftJoin(items, sql`${techSalesRfqs.itemId} = ${items.id}`) + .where(where ?? undefined); + return res[0]?.count ?? 0; +} + +/** + * 벤더 견적서 직접 조인 조회 (뷰 대신 테이블 조인 사용) + */ +export async function selectTechSalesVendorQuotationsWithJoin( + tx: PgTransaction, + params: { + where?: any; + orderBy?: (ReturnType | ReturnType | SQL)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행 + const query = tx.select({ + // 견적 기본 정보 + id: techSalesVendorQuotations.id, + rfqId: techSalesVendorQuotations.rfqId, + rfqCode: techSalesRfqs.rfqCode, + vendorId: techSalesVendorQuotations.vendorId, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + + // 견적 상세 정보 + totalPrice: techSalesVendorQuotations.totalPrice, + currency: techSalesVendorQuotations.currency, + validUntil: techSalesVendorQuotations.validUntil, + status: techSalesVendorQuotations.status, + remark: techSalesVendorQuotations.remark, + rejectionReason: techSalesVendorQuotations.rejectionReason, + + // 날짜 정보 + submittedAt: techSalesVendorQuotations.submittedAt, + acceptedAt: techSalesVendorQuotations.acceptedAt, + createdAt: techSalesVendorQuotations.createdAt, + updatedAt: techSalesVendorQuotations.updatedAt, + + // 생성/수정 사용자 + createdBy: techSalesVendorQuotations.createdBy, + createdByName: sql`created_user.name`, + updatedBy: techSalesVendorQuotations.updatedBy, + updatedByName: sql`updated_user.name`, + + // 프로젝트 정보 + materialCode: techSalesRfqs.materialCode, + itemId: techSalesRfqs.itemId, + itemName: items.itemName, + + // 프로젝트 핵심 정보 + pspid: sql`${techSalesRfqs.projectSnapshot}->>'pspid'`, + projNm: sql`${techSalesRfqs.projectSnapshot}->>'projNm'`, + sector: sql`${techSalesRfqs.projectSnapshot}->>'sector'`, + + // 첨부파일 개수 + attachmentCount: sql`( + SELECT COUNT(*) + FROM tech_sales_rfq_comment_attachments + WHERE tech_sales_rfq_comment_attachments.quotation_id = ${techSalesVendorQuotations.id} + )`, + }) + .from(techSalesVendorQuotations) + .leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`) + .leftJoin(vendors, sql`${techSalesVendorQuotations.vendorId} = ${vendors.id}`) + .leftJoin(items, sql`${techSalesRfqs.itemId} = ${items.id}`) + .leftJoin(sql`${users} AS created_user`, sql`${techSalesVendorQuotations.createdBy} = created_user.id`) + .leftJoin(sql`${users} AS updated_user`, sql`${techSalesVendorQuotations.updatedBy} = updated_user.id`); + + // where 조건 적용 + const queryWithWhere = where ? query.where(where) : query; + + // orderBy 적용 + const queryWithOrderBy = orderBy?.length + ? queryWithWhere.orderBy(...orderBy) + : queryWithWhere.orderBy(desc(techSalesVendorQuotations.createdAt)); + + // offset과 limit 적용 후 실행 + return queryWithOrderBy.offset(offset).limit(limit); +} + +/** + * 벤더 견적서 개수 직접 조회 (뷰 대신 테이블 조인 사용) + */ +export async function countTechSalesVendorQuotationsWithJoin( + tx: PgTransaction, + where?: any +) { + const res = await tx + .select({ count: count() }) + .from(techSalesVendorQuotations) + .leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`) + .leftJoin(vendors, sql`${techSalesVendorQuotations.vendorId} = ${vendors.id}`) + .where(where ?? undefined); + return res[0]?.count ?? 0; +} + +/** + * RFQ 대시보드 데이터 직접 조인 조회 (뷰 대신 테이블 조인 사용) + */ +export async function selectTechSalesDashboardWithJoin( + tx: PgTransaction, + params: { + where?: any; + orderBy?: (ReturnType | ReturnType | SQL)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행 + const query = tx.select({ + // RFQ 기본 정보 + id: techSalesRfqs.id, + rfqCode: techSalesRfqs.rfqCode, + status: techSalesRfqs.status, + dueDate: techSalesRfqs.dueDate, + rfqSendDate: techSalesRfqs.rfqSendDate, + materialCode: techSalesRfqs.materialCode, + + // 아이템 정보 + itemId: techSalesRfqs.itemId, + itemName: items.itemName, + + // 프로젝트 정보 + pspid: sql`${techSalesRfqs.projectSnapshot}->>'pspid'`, + projNm: sql`${techSalesRfqs.projectSnapshot}->>'projNm'`, + sector: sql`${techSalesRfqs.projectSnapshot}->>'sector'`, + projMsrm: sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`, + ptypeNm: sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`, + + // 벤더 견적 통계 + vendorCount: sql`( + SELECT COUNT(DISTINCT vendor_id) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + )`, + + quotationCount: sql`( + SELECT COUNT(*) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + )`, + + submittedQuotationCount: sql`( + SELECT COUNT(*) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + AND tech_sales_vendor_quotations.status = 'Submitted' + )`, + + minPrice: sql`( + SELECT MIN(total_price) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + AND tech_sales_vendor_quotations.status = 'Submitted' + )`, + + maxPrice: sql`( + SELECT MAX(total_price) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + AND tech_sales_vendor_quotations.status = 'Submitted' + )`, + + avgPrice: sql`( + SELECT AVG(total_price) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + AND tech_sales_vendor_quotations.status = 'Submitted' + )`, + + // 첨부파일 통계 + attachmentCount: sql`( + SELECT COUNT(*) + FROM tech_sales_attachments + WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} + )`, + + // 코멘트 통계 + commentCount: sql`( + SELECT COUNT(*) + FROM tech_sales_rfq_comments + WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id} + )`, + + unreadCommentCount: sql`( + SELECT COUNT(*) + FROM tech_sales_rfq_comments + WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id} + AND tech_sales_rfq_comments.is_read = false + )`, + + // 생성/수정 정보 + createdAt: techSalesRfqs.createdAt, + updatedAt: techSalesRfqs.updatedAt, + createdByName: sql`created_user.name`, + }) + .from(techSalesRfqs) + .leftJoin(items, sql`${techSalesRfqs.itemId} = ${items.id}`) + .leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`); + + // where 조건 적용 + const queryWithWhere = where ? query.where(where) : query; + + // orderBy 적용 + const queryWithOrderBy = orderBy?.length + ? queryWithWhere.orderBy(...orderBy) + : queryWithWhere.orderBy(desc(techSalesRfqs.updatedAt)); + + // offset과 limit 적용 후 실행 + return queryWithOrderBy.offset(offset).limit(limit); +} + diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts new file mode 100644 index 00000000..88fef4b7 --- /dev/null +++ b/lib/techsales-rfq/service.ts @@ -0,0 +1,1540 @@ +'use server' + +import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache"; +import db from "@/db/db"; +import { + techSalesRfqs, + techSalesVendorQuotations, + items, + users, + TECH_SALES_QUOTATION_STATUSES +} from "@/db/schema"; +import { and, desc, eq, ilike, or, sql, ne } from "drizzle-orm"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { getErrorMessage } from "@/lib/handle-error"; +import type { Filter } from "@/types/table"; +import { + selectTechSalesRfqsWithJoin, + countTechSalesRfqsWithJoin, + selectTechSalesVendorQuotationsWithJoin, + countTechSalesVendorQuotationsWithJoin, + selectTechSalesDashboardWithJoin +} from "./repository"; +import { GetTechSalesRfqsSchema } from "./validations"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { sendEmail } from "../mail/sendEmail"; +import { formatDate, formatDateToQuarter } from "../utils"; + +// 정렬 타입 정의 +// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type OrderByType = any; + +// 시리즈 스냅샷 타입 정의 +interface SeriesSnapshot { + pspid: string; + sersNo: string; + scDt?: string; + klDt?: string; + lcDt?: string; + dlDt?: string; + dockNo?: string; + dockNm?: string; + projNo?: string; + post1?: string; +} + +/** + * 연도별 순차 RFQ 코드 생성 함수 (다중 생성 지원) + * 형식: RFQ-YYYY-001, RFQ-YYYY-002, ... + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function generateRfqCodes(tx: any, count: number, year?: number): Promise { + const currentYear = year || new Date().getFullYear(); + const yearPrefix = `RFQ-${currentYear}-`; + + // 해당 연도의 가장 최근 RFQ 코드 조회 + const latestRfq = await tx + .select({ rfqCode: techSalesRfqs.rfqCode }) + .from(techSalesRfqs) + .where(ilike(techSalesRfqs.rfqCode, `${yearPrefix}%`)) + .orderBy(desc(techSalesRfqs.rfqCode)) + .limit(1); + + let nextNumber = 1; + + if (latestRfq.length > 0) { + // 기존 코드에서 번호 추출 (RFQ-2024-001 -> 001) + const lastCode = latestRfq[0].rfqCode; + const numberPart = lastCode.split('-').pop(); + if (numberPart) { + const lastNumber = parseInt(numberPart, 10); + if (!isNaN(lastNumber)) { + nextNumber = lastNumber + 1; + } + } + } + + // 요청된 개수만큼 순차적으로 코드 생성 + const codes: string[] = []; + for (let i = 0; i < count; i++) { + const paddedNumber = (nextNumber + i).toString().padStart(3, '0'); + codes.push(`${yearPrefix}${paddedNumber}`); + } + + return codes; +} + +/** + * 기술영업 조선 RFQ 생성 액션 + * + * 받을 파라미터 (생성시 입력하는 것) + * 1. RFQ 관련 + * 2. 프로젝트 관련 + * 3. 자재 관련 (자재그룹) + * + * 나머지 벤더, 첨부파일 등은 생성 이후 처리 + */ +export async function createTechSalesRfq(input: { + // 프로젝트 관련 + biddingProjectId: number; + // 자재 관련 (자재그룹 코드들) + materialGroupCodes: string[]; + // 기본 정보 + dueDate?: Date; + remark?: string; + createdBy: number; +}) { + unstable_noStore(); + try { + const results: typeof techSalesRfqs.$inferSelect[] = []; + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 실제 프로젝트 정보 조회 + const biddingProject = await tx.query.biddingProjects.findFirst({ + where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId) + }); + + if (!biddingProject) { + throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`); + } + + // 프로젝트 시리즈 정보 조회 + const seriesInfo = await tx.query.projectSeries.findMany({ + where: (projectSeries, { eq }) => eq(projectSeries.pspid, biddingProject.pspid) + }); + + // 프로젝트 스냅샷 생성 + const projectSnapshot = { + pspid: biddingProject.pspid, + projNm: biddingProject.projNm || undefined, + sector: biddingProject.sector || undefined, + projMsrm: biddingProject.projMsrm ? Number(biddingProject.projMsrm) : undefined, + kunnr: biddingProject.kunnr || undefined, + kunnrNm: biddingProject.kunnrNm || undefined, + cls1: biddingProject.cls1 || undefined, + cls1Nm: biddingProject.cls1Nm || undefined, + ptype: biddingProject.ptype || undefined, + ptypeNm: biddingProject.ptypeNm || undefined, + pmodelCd: biddingProject.pmodelCd || undefined, + pmodelNm: biddingProject.pmodelNm || undefined, + pmodelSz: biddingProject.pmodelSz || undefined, + pmodelUom: biddingProject.pmodelUom || undefined, + txt04: biddingProject.txt04 || undefined, + txt30: biddingProject.txt30 || undefined, + estmPm: biddingProject.estmPm || undefined, + pspCreatedAt: biddingProject.createdAt, + pspUpdatedAt: biddingProject.updatedAt, + }; + + // 시리즈 스냅샷 생성 + const seriesSnapshot = seriesInfo.map(series => ({ + pspid: series.pspid, + sersNo: series.sersNo.toString(), + scDt: series.scDt || undefined, + klDt: series.klDt || undefined, + lcDt: series.lcDt || undefined, + dlDt: series.dlDt || undefined, + dockNo: series.dockNo || undefined, + dockNm: series.dockNm || undefined, + projNo: series.projNo || undefined, + post1: series.post1 || undefined, + })); + + // 각 자재그룹 코드별로 RFQ 생성 + for (const materialCode of input.materialGroupCodes) { + // RFQ 코드 생성 (임시로 타임스탬프 기반) + const rfqCode = await generateRfqCodes(tx, 1); + + // 기본 due date 설정 (7일 후) + const dueDate = input.dueDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + // 기존 item 확인 또는 새로 생성 + let itemId: number; + const existingItem = await tx.query.items.findFirst({ + where: (items, { eq }) => eq(items.itemCode, materialCode), + columns: { id: true } + }); + + if (existingItem) { + // 기존 item 사용 + itemId = existingItem.id; + } else { + // 새 item 생성 + const [newItem] = await tx.insert(items).values({ + itemCode: materialCode, + itemName: `자재그룹 ${materialCode}`, + description: `기술영업 자재그룹`, + }).returning(); + itemId = newItem.id; + } + + // 새 기술영업 RFQ 작성 (스냅샷 포함) + const [newRfq] = await tx.insert(techSalesRfqs).values({ + rfqCode: rfqCode[0], + itemId: itemId, + biddingProjectId: input.biddingProjectId, + materialCode, + dueDate, + remark: input.remark, + createdBy: input.createdBy, + updatedBy: input.createdBy, + // 스냅샷 데이터 추가 + projectSnapshot, + seriesSnapshot, + }).returning(); + + results.push(newRfq); + } + }); + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + revalidatePath("/evcp/budgetary-tech-sales-ship"); + + return { data: results, error: null }; + } catch (err) { + console.error("Error creating RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 직접 조인을 사용하여 RFQ 데이터 조회하는 함수 + * 페이지네이션, 필터링, 정렬 등 지원 + */ +export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 기본 필터 처리 - RFQFilterBox에서 오는 필터 + const basicFilters = input.basicFilters || []; + const basicJoinOperator = input.basicJoinOperator || "and"; + + // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터 + const advancedFilters = input.filters || []; + const advancedJoinOperator = input.joinOperator || "and"; + + // 기본 필터 조건 생성 + let basicWhere; + if (basicFilters.length > 0) { + basicWhere = filterColumns({ + table: techSalesRfqs, + filters: basicFilters, + joinOperator: basicJoinOperator, + }); + } + + // 고급 필터 조건 생성 + let advancedWhere; + if (advancedFilters.length > 0) { + advancedWhere = filterColumns({ + table: techSalesRfqs, + filters: advancedFilters, + joinOperator: advancedJoinOperator, + }); + } + + // 전역 검색 조건 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(techSalesRfqs.rfqCode, s), + ilike(techSalesRfqs.materialCode, s), + // JSON 필드 검색 + sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${s}`, + sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${s}`, + sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm' ILIKE ${s}` + ); + } + + // 모든 조건 결합 + const whereConditions = []; + if (basicWhere) whereConditions.push(basicWhere); + if (advancedWhere) whereConditions.push(advancedWhere); + if (globalWhere) whereConditions.push(globalWhere); + + // 조건이 있을 때만 and() 사용 + const finalWhere = whereConditions.length > 0 + ? and(...whereConditions) + : undefined; + + // 정렬 기준 설정 + let orderBy: OrderByType[] = [desc(techSalesRfqs.createdAt)]; // 기본 정렬 + + if (input.sort?.length) { + // 안전하게 접근하여 정렬 기준 설정 + orderBy = input.sort.map(item => { + switch (item.id) { + case 'id': + return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id; + case 'rfqCode': + return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; + case 'materialCode': + return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode; + case 'status': + return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status; + case 'dueDate': + return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate; + case 'createdAt': + return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; + case 'updatedAt': + return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt; + default: + return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; + } + }); + } + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectTechSalesRfqsWithJoin(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countTechSalesRfqsWithJoin(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount, total }; + } catch (err) { + console.error("Error fetching RFQs with join:", err); + return { data: [], pageCount: 0, total: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 60, // 1분간 캐시 + tags: ["techSalesRfqs"], + } + )(); +} + +/** + * 직접 조인을 사용하여 벤더 견적서 조회하는 함수 + */ +export async function getTechSalesVendorQuotationsWithJoin(input: { + rfqId?: number; + vendorId?: number; + search?: string; + filters?: Filter[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 기본 필터 조건들 + const whereConditions = []; + + // RFQ ID 필터 + if (input.rfqId) { + whereConditions.push(eq(techSalesVendorQuotations.rfqId, input.rfqId)); + } + + // 벤더 ID 필터 + if (input.vendorId) { + whereConditions.push(eq(techSalesVendorQuotations.vendorId, input.vendorId)); + } + + // 검색 조건 + if (input.search) { + const s = `%${input.search}%`; + const searchCondition = or( + ilike(techSalesVendorQuotations.currency, s), + ilike(techSalesVendorQuotations.status, s) + ); + if (searchCondition) { + whereConditions.push(searchCondition); + } + } + + // 고급 필터 처리 + if (input.filters && input.filters.length > 0) { + const filterWhere = filterColumns({ + table: techSalesVendorQuotations, + filters: input.filters as Filter[], + joinOperator: "and", + }); + if (filterWhere) { + whereConditions.push(filterWhere); + } + } + + // 최종 WHERE 조건 + const finalWhere = whereConditions.length > 0 + ? and(...whereConditions) + : undefined; + + // 정렬 기준 설정 + let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.createdAt)]; + + if (input.sort?.length) { + orderBy = input.sort.map(item => { + switch (item.id) { + case 'id': + return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id; + case 'status': + return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status; + case 'currency': + return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency; + case 'totalPrice': + return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice; + case 'createdAt': + return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; + case 'updatedAt': + return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; + default: + return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; + } + }); + } + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectTechSalesVendorQuotationsWithJoin(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount, total }; + } catch (err) { + console.error("Error fetching vendor quotations with join:", err); + return { data: [], pageCount: 0, total: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 60, + tags: [ + "techSalesVendorQuotations", + ...(input.rfqId ? [`techSalesRfq-${input.rfqId}`] : []) + ], + } + )(); +} + +/** + * 직접 조인을 사용하여 RFQ 대시보드 데이터 조회하는 함수 + */ +export async function getTechSalesDashboardWithJoin(input: { + search?: string; + filters?: Filter[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + unstable_noStore(); // 대시보드는 항상 최신 데이터를 보여주기 위해 캐시하지 않음 + + try { + const offset = (input.page - 1) * input.perPage; + + // Advanced filtering + const advancedWhere = input.filters ? filterColumns({ + table: techSalesRfqs, + filters: input.filters as Filter[], + joinOperator: 'and', + }) : undefined; + + // Global search + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(techSalesRfqs.rfqCode, s), + ilike(techSalesRfqs.materialCode, s), + // JSON 필드 검색 + sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${s}`, + sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${s}` + ); + } + + const finalWhere = and( + advancedWhere, + globalWhere + ); + + // 정렬 기준 설정 + let orderBy: OrderByType[] = [desc(techSalesRfqs.updatedAt)]; // 기본 정렬 + + if (input.sort?.length) { + // 안전하게 접근하여 정렬 기준 설정 + orderBy = input.sort.map(item => { + switch (item.id) { + case 'id': + return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id; + case 'rfqCode': + return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; + case 'status': + return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status; + case 'dueDate': + return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate; + case 'createdAt': + return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; + case 'updatedAt': + return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt; + default: + return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt; + } + }); + } + + // 트랜잭션 내부에서 Repository 호출 + const data = await db.transaction(async (tx) => { + return await selectTechSalesDashboardWithJoin(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + }); + + return { data, success: true }; + } catch (err) { + console.error("Error fetching dashboard data with join:", err); + return { data: [], success: false, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ에 벤더 추가 (단일) + */ +export async function addVendorToTechSalesRfq(input: { + rfqId: number; + vendorId: number; + createdBy: number; +}) { + unstable_noStore(); + try { + // 이미 해당 RFQ에 벤더가 추가되어 있는지 확인 + const existingQuotation = await db + .select() + .from(techSalesVendorQuotations) + .where( + and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, input.vendorId) + ) + ) + .limit(1); + + if (existingQuotation.length > 0) { + return { + data: null, + error: "이미 해당 벤더가 이 RFQ에 추가되어 있습니다." + }; + } + + // 새 벤더 견적서 레코드 생성 + const [newQuotation] = await db + .insert(techSalesVendorQuotations) + .values({ + rfqId: input.rfqId, + vendorId: input.vendorId, + status: "Draft", + totalPrice: "0", + currency: "USD", + createdBy: input.createdBy, + updatedBy: input.createdBy, + }) + .returning(); + + return { data: newQuotation, error: null }; + } catch (err) { + console.error("Error adding vendor to RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ에 여러 벤더 추가 (다중) + */ +export async function addVendorsToTechSalesRfq(input: { + rfqId: number; + vendorIds: number[]; + createdBy: number; +}) { + unstable_noStore(); + try { + const results: typeof techSalesVendorQuotations.$inferSelect[] = []; + const errors: string[] = []; + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. RFQ 상태 확인 + const rfq = await tx.query.techSalesRfqs.findFirst({ + where: eq(techSalesRfqs.id, input.rfqId), + columns: { + id: true, + status: true + } + }); + + if (!rfq) { + throw new Error("RFQ를 찾을 수 없습니다"); + } + + // 2. 각 벤더에 대해 처리 + for (const vendorId of input.vendorIds) { + try { + // 이미 해당 RFQ에 벤더가 추가되어 있는지 확인 + const existingQuotation = await tx + .select() + .from(techSalesVendorQuotations) + .where( + and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, vendorId) + ) + ) + .limit(1); + + if (existingQuotation.length > 0) { + errors.push(`벤더 ID ${vendorId}는 이미 추가되어 있습니다.`); + continue; + } + + // 새 벤더 견적서 레코드 생성 + const [newQuotation] = await tx + .insert(techSalesVendorQuotations) + .values({ + rfqId: input.rfqId, + vendorId: vendorId, + status: "Draft", + totalPrice: "0", + currency: "USD", + createdBy: input.createdBy, + updatedBy: input.createdBy, + }) + .returning(); + + results.push(newQuotation); + } catch (vendorError) { + console.error(`Error adding vendor ${vendorId}:`, vendorError); + errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`); + } + } + + // 3. RFQ 상태가 "RFQ Created"이고 성공적으로 추가된 벤더가 있는 경우 상태 업데이트 + if (rfq.status === "RFQ Created" && results.length > 0) { + await tx.update(techSalesRfqs) + .set({ + status: "RFQ Vendor Assignned", + updatedBy: input.createdBy, + updatedAt: new Date() + }) + .where(eq(techSalesRfqs.id, input.rfqId)); + } + }); + + // 캐시 무효화 추가 + revalidateTag("techSalesVendorQuotations"); + revalidateTag(`techSalesRfq-${input.rfqId}`); + revalidatePath("/evcp/budgetary-tech-sales-ship"); + + return { + data: results, + error: errors.length > 0 ? errors.join(", ") : null, + successCount: results.length, + errorCount: errors.length + }; + } catch (err) { + console.error("Error adding vendors to RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ에서 벤더 제거 (Draft 상태 체크 포함) + */ +export async function removeVendorFromTechSalesRfq(input: { + rfqId: number; + vendorId: number; +}) { + unstable_noStore(); + try { + // 먼저 해당 벤더의 견적서 상태 확인 + const existingQuotation = await db + .select() + .from(techSalesVendorQuotations) + .where( + and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, input.vendorId) + ) + ) + .limit(1); + + if (existingQuotation.length === 0) { + return { + data: null, + error: "해당 벤더가 이 RFQ에 존재하지 않습니다." + }; + } + + // Draft 상태가 아닌 경우 삭제 불가 + if (existingQuotation[0].status !== "Draft") { + return { + data: null, + error: "Draft 상태의 벤더만 삭제할 수 있습니다." + }; + } + + // 해당 벤더의 견적서 삭제 + const deletedQuotations = await db + .delete(techSalesVendorQuotations) + .where( + and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, input.vendorId) + ) + ) + .returning(); + + // 캐시 무효화 추가 + revalidateTag("techSalesVendorQuotations"); + revalidateTag(`techSalesRfq-${input.rfqId}`); + revalidatePath("/evcp/budgetary-tech-sales-ship"); + + return { data: deletedQuotations[0], error: null }; + } catch (err) { + console.error("Error removing vendor from RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ에서 여러 벤더 일괄 제거 (Draft 상태 체크 포함) + */ +export async function removeVendorsFromTechSalesRfq(input: { + rfqId: number; + vendorIds: number[]; +}) { + unstable_noStore(); + try { + const results: typeof techSalesVendorQuotations.$inferSelect[] = []; + const errors: string[] = []; + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + for (const vendorId of input.vendorIds) { + try { + // 먼저 해당 벤더의 견적서 상태 확인 + const existingQuotation = await tx + .select() + .from(techSalesVendorQuotations) + .where( + and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, vendorId) + ) + ) + .limit(1); + + if (existingQuotation.length === 0) { + errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`); + continue; + } + + // Draft 상태가 아닌 경우 삭제 불가 + if (existingQuotation[0].status !== "Draft") { + errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`); + continue; + } + + // 해당 벤더의 견적서 삭제 + const deletedQuotations = await tx + .delete(techSalesVendorQuotations) + .where( + and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + eq(techSalesVendorQuotations.vendorId, vendorId) + ) + ) + .returning(); + + if (deletedQuotations.length > 0) { + results.push(deletedQuotations[0]); + } + } catch (vendorError) { + console.error(`Error removing vendor ${vendorId}:`, vendorError); + errors.push(`벤더 ID ${vendorId} 삭제 중 오류가 발생했습니다.`); + } + } + }); + + // 캐시 무효화 추가 + revalidateTag("techSalesVendorQuotations"); + revalidateTag(`techSalesRfq-${input.rfqId}`); + revalidatePath("/evcp/budgetary-tech-sales-ship"); + + return { + data: results, + error: errors.length > 0 ? errors.join(", ") : null, + successCount: results.length, + errorCount: errors.length + }; + } catch (err) { + console.error("Error removing vendors from RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 특정 RFQ의 벤더 목록 조회 + */ +export async function getTechSalesRfqVendors(rfqId: number) { + unstable_noStore(); + try { + // Repository 함수를 사용하여 벤더 견적 목록 조회 + const result = await getTechSalesVendorQuotationsWithJoin({ + rfqId, + page: 1, + perPage: 1000, // 충분히 큰 수로 설정하여 모든 벤더 조회 + }); + + return { data: result.data, error: null }; + } catch (err) { + console.error("Error fetching RFQ vendors:", err); + return { data: [], error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ 발송 (선택된 벤더들에게) + */ +export async function sendTechSalesRfqToVendors(input: { + rfqId: number; + vendorIds: number[]; +}) { + unstable_noStore(); + try { + // 인증 확인 + const session = await getServerSession(authOptions); + + if (!session?.user) { + return { + success: false, + message: "인증이 필요합니다", + }; + } + + // RFQ 정보 조회 + const rfq = await db.query.techSalesRfqs.findFirst({ + where: eq(techSalesRfqs.id, input.rfqId), + columns: { + id: true, + rfqCode: true, + status: true, + dueDate: true, + rfqSendDate: true, + remark: true, + materialCode: true, + projectSnapshot: true, + seriesSnapshot: true, + }, + with: { + item: { + columns: { + id: true, + itemCode: true, + itemName: true, + } + }, + biddingProject: { + columns: { + id: true, + pspid: true, + projNm: true, + sector: true, + ptypeNm: true, + } + }, + createdByUser: { + columns: { + id: true, + name: true, + email: true, + } + } + } + }); + + if (!rfq) { + return { + success: false, + message: "RFQ를 찾을 수 없습니다", + }; + } + + // 발송 가능한 상태인지 확인 + if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") { + return { + success: false, + message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다", + }; + } + + const isResend = rfq.status === "RFQ Sent"; + + // 현재 사용자 정보 조회 + const sender = await db.query.users.findFirst({ + where: eq(users.id, Number(session.user.id)), + columns: { + id: true, + email: true, + name: true, + } + }); + + if (!sender || !sender.email) { + return { + success: false, + message: "보내는 사람의 이메일 정보를 찾을 수 없습니다", + }; + } + + // 선택된 벤더들의 견적서 정보 조회 + const vendorQuotations = await db.query.techSalesVendorQuotations.findMany({ + where: and( + eq(techSalesVendorQuotations.rfqId, input.rfqId), + sql`${techSalesVendorQuotations.vendorId} IN (${input.vendorIds.join(',')})` + ), + columns: { + id: true, + vendorId: true, + status: true, + currency: true, + }, + with: { + vendor: { + columns: { + id: true, + vendorName: true, + vendorCode: true, + } + } + } + }); + + if (vendorQuotations.length === 0) { + return { + success: false, + message: "선택된 벤더가 이 RFQ에 할당되어 있지 않습니다", + }; + } + + // 트랜잭션 시작 + await db.transaction(async (tx) => { + // 1. RFQ 상태 업데이트 (첫 발송인 경우에만) + if (!isResend) { + await tx.update(techSalesRfqs) + .set({ + status: "RFQ Sent", + rfqSendDate: new Date(), + sentBy: Number(session.user.id), + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }) + .where(eq(techSalesRfqs.id, input.rfqId)); + } + + // 2. 각 벤더에 대해 이메일 발송 처리 + for (const quotation of vendorQuotations) { + if (!quotation.vendorId || !quotation.vendor) continue; + + // 벤더에 속한 모든 사용자 조회 + const vendorUsers = await db.query.users.findMany({ + where: eq(users.companyId, quotation.vendorId), + columns: { + id: true, + email: true, + name: true, + language: true + } + }); + + // 유효한 이메일 주소만 필터링 + const vendorEmailsString = vendorUsers + .filter(user => user.email) + .map(user => user.email) + .join(", "); + + if (vendorEmailsString) { + // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값) + const language = vendorUsers[0]?.language || "ko"; + + // 시리즈 정보 처리 + const seriesInfo = rfq.seriesSnapshot ? rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ + sersNo: series.sersNo, + klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', + scDt: series.scDt, + lcDt: series.lcDt, + dlDt: series.dlDt, + dockNo: series.dockNo, + dockNm: series.dockNm, + projNo: series.projNo, + post1: series.post1, + })) : []; + + // 이메일 컨텍스트 구성 + const emailContext = { + language: language, + rfq: { + id: rfq.id, + code: rfq.rfqCode, + title: rfq.item?.itemName || '', + projectCode: rfq.biddingProject?.pspid || '', + projectName: rfq.biddingProject?.projNm || '', + description: rfq.remark || '', + dueDate: rfq.dueDate ? formatDate(rfq.dueDate) : 'N/A', + materialCode: rfq.materialCode || '', + }, + vendor: { + id: quotation.vendor.id, + code: quotation.vendor.vendorCode || '', + name: quotation.vendor.vendorName, + }, + sender: { + fullName: sender.name || '', + email: sender.email, + }, + project: { + // 기본 정보 + id: rfq.projectSnapshot?.pspid || rfq.biddingProject?.pspid || '', + name: rfq.projectSnapshot?.projNm || rfq.biddingProject?.projNm || '', + sector: rfq.projectSnapshot?.sector || rfq.biddingProject?.sector || '', + shipType: rfq.projectSnapshot?.ptypeNm || rfq.biddingProject?.ptypeNm || '', + + // 추가 프로젝트 정보 + shipCount: rfq.projectSnapshot?.projMsrm || 0, + ownerCode: rfq.projectSnapshot?.kunnr || '', + ownerName: rfq.projectSnapshot?.kunnrNm || '', + classCode: rfq.projectSnapshot?.cls1 || '', + className: rfq.projectSnapshot?.cls1Nm || '', + shipTypeCode: rfq.projectSnapshot?.ptype || '', + shipModelCode: rfq.projectSnapshot?.pmodelCd || '', + shipModelName: rfq.projectSnapshot?.pmodelNm || '', + shipModelSize: rfq.projectSnapshot?.pmodelSz || '', + shipModelUnit: rfq.projectSnapshot?.pmodelUom || '', + estimateStatus: rfq.projectSnapshot?.txt30 || '', + projectManager: rfq.projectSnapshot?.estmPm || '', + }, + series: seriesInfo, + details: { + currency: quotation.currency || 'USD', + }, + quotationCode: `${rfq.rfqCode}-${quotation.vendorId}`, + systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners', + isResend: isResend, + versionInfo: isResend ? '(재전송)' : '', + }; + + // 이메일 전송 + await sendEmail({ + to: vendorEmailsString, + subject: isResend + ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'} ${emailContext.versionInfo}` + : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'}`, + template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿 + context: emailContext, + cc: sender.email, // 발신자를 CC에 추가 + }); + } + } + }); + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + revalidateTag("techSalesVendorQuotations"); + revalidateTag(`techSalesRfq-${input.rfqId}`); + revalidatePath("/evcp/budgetary-tech-sales-ship"); + + return { + success: true, + message: `${vendorQuotations.length}개 벤더에게 RFQ가 성공적으로 발송되었습니다`, + sentCount: vendorQuotations.length, + }; + } catch (err) { + console.error("기술영업 RFQ 발송 오류:", err); + return { + success: false, + message: "RFQ 발송 중 오류가 발생했습니다", + }; + } +} + +/** + * 벤더용 기술영업 RFQ 견적서 조회 + */ +export async function getTechSalesVendorQuotation(quotationId: number) { + unstable_noStore(); + try { + const quotation = await db.query.techSalesVendorQuotations.findFirst({ + where: eq(techSalesVendorQuotations.id, quotationId), + with: { + rfq: { + with: { + item: true, + biddingProject: true, + createdByUser: { + columns: { + id: true, + name: true, + email: true, + } + } + } + }, + vendor: true, + } + }); + + if (!quotation) { + return { data: null, error: "견적서를 찾을 수 없습니다." }; + } + + return { data: quotation, error: null }; + } catch (err) { + console.error("Error fetching vendor quotation:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 벤더 견적서 업데이트 (임시저장) + */ +export async function updateTechSalesVendorQuotation(data: { + id: number + currency: string + totalPrice: string + validUntil: Date + remark?: string + updatedBy: number +}) { + try { + // 현재 견적서 상태 확인 + const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({ + where: eq(techSalesVendorQuotations.id, data.id), + columns: { + status: true, + } + }); + + if (!currentQuotation) { + return { data: null, error: "견적서를 찾을 수 없습니다." }; + } + + // Draft 또는 Revised 상태에서만 수정 가능 + if (!["Draft", "Revised"].includes(currentQuotation.status)) { + return { data: null, error: "현재 상태에서는 견적서를 수정할 수 없습니다." }; + } + + const result = await db + .update(techSalesVendorQuotations) + .set({ + currency: data.currency, + totalPrice: data.totalPrice, + validUntil: data.validUntil, + remark: data.remark || null, + updatedAt: new Date(), + }) + .where(eq(techSalesVendorQuotations.id, data.id)) + .returning() + + // 캐시 무효화 + revalidateTag("techSalesVendorQuotations") + revalidatePath(`/partners/techsales/rfq-ship/${data.id}`) + + return { data: result[0], error: null } + } catch (error) { + console.error("Error updating tech sales vendor quotation:", error) + return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" } + } +} + +/** + * 기술영업 벤더 견적서 제출 + */ +export async function submitTechSalesVendorQuotation(data: { + id: number + currency: string + totalPrice: string + validUntil: Date + remark?: string + updatedBy: number +}) { + try { + // 현재 견적서 상태 확인 + const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({ + where: eq(techSalesVendorQuotations.id, data.id), + columns: { + status: true, + } + }); + + if (!currentQuotation) { + return { data: null, error: "견적서를 찾을 수 없습니다." }; + } + + // Draft 또는 Revised 상태에서만 제출 가능 + if (!["Draft", "Revised"].includes(currentQuotation.status)) { + return { data: null, error: "현재 상태에서는 견적서를 제출할 수 없습니다." }; + } + + const result = await db + .update(techSalesVendorQuotations) + .set({ + currency: data.currency, + totalPrice: data.totalPrice, + validUntil: data.validUntil, + remark: data.remark || null, + status: "Submitted", + submittedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(techSalesVendorQuotations.id, data.id)) + .returning() + + // 캐시 무효화 + revalidateTag("techSalesVendorQuotations") + revalidatePath(`/partners/techsales/rfq-ship/${data.id}`) + + return { data: result[0], error: null } + } catch (error) { + console.error("Error submitting tech sales vendor quotation:", error) + return { data: null, error: "견적서 제출 중 오류가 발생했습니다" } + } +} + +/** + * 통화 목록 조회 + */ +export async function fetchCurrencies() { + try { + // 기본 통화 목록 (실제로는 DB에서 가져와야 함) + const currencies = [ + { code: "USD", name: "미국 달러" }, + { code: "KRW", name: "한국 원" }, + { code: "EUR", name: "유로" }, + { code: "JPY", name: "일본 엔" }, + { code: "CNY", name: "중국 위안" }, + ] + + return { data: currencies, error: null } + } catch (error) { + console.error("Error fetching currencies:", error) + return { data: null, error: "통화 목록 조회 중 오류가 발생했습니다" } + } +} + +/** + * 벤더용 기술영업 견적서 목록 조회 (페이지네이션 포함) + */ +export async function getVendorQuotations(input: { + flags?: string[]; + page: number; + perPage: number; + sort?: { id: string; desc: boolean }[]; + filters?: Filter[]; + joinOperator?: "and" | "or"; + basicFilters?: Filter[]; + basicJoinOperator?: "and" | "or"; + search?: string; + from?: string; + to?: string; +}, vendorId: string) { + unstable_noStore(); + try { + const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input; + const offset = (page - 1) * perPage; + const limit = perPage; + + // 기본 조건: 해당 벤더의 견적서만 조회 + const baseConditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))]; + + // 검색 조건 추가 + if (search) { + const s = `%${search}%`; + const searchCondition = or( + ilike(techSalesVendorQuotations.currency, s), + ilike(techSalesVendorQuotations.status, s) + ); + if (searchCondition) { + baseConditions.push(searchCondition); + } + } + + // 날짜 범위 필터 + if (from) { + baseConditions.push(sql`${techSalesVendorQuotations.createdAt} >= ${from}`); + } + if (to) { + baseConditions.push(sql`${techSalesVendorQuotations.createdAt} <= ${to}`); + } + + // 고급 필터 처리 + if (filters.length > 0) { + const filterWhere = filterColumns({ + table: techSalesVendorQuotations, + filters: filters as Filter[], + joinOperator: input.joinOperator || "and", + }); + if (filterWhere) { + baseConditions.push(filterWhere); + } + } + + // 최종 WHERE 조건 + const finalWhere = baseConditions.length > 0 + ? and(...baseConditions) + : undefined; + + // 정렬 기준 설정 + let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.updatedAt)]; + + if (sort?.length) { + orderBy = sort.map(item => { + switch (item.id) { + case 'id': + return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id; + case 'status': + return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status; + case 'currency': + return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency; + case 'totalPrice': + return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice; + case 'validUntil': + return item.desc ? desc(techSalesVendorQuotations.validUntil) : techSalesVendorQuotations.validUntil; + case 'submittedAt': + return item.desc ? desc(techSalesVendorQuotations.submittedAt) : techSalesVendorQuotations.submittedAt; + case 'createdAt': + return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; + case 'updatedAt': + return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; + default: + return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; + } + }); + } + + // 조인을 포함한 데이터 조회 + const data = await db + .select({ + id: techSalesVendorQuotations.id, + rfqId: techSalesVendorQuotations.rfqId, + vendorId: techSalesVendorQuotations.vendorId, + status: techSalesVendorQuotations.status, + currency: techSalesVendorQuotations.currency, + totalPrice: techSalesVendorQuotations.totalPrice, + validUntil: techSalesVendorQuotations.validUntil, + submittedAt: techSalesVendorQuotations.submittedAt, + remark: techSalesVendorQuotations.remark, + createdAt: techSalesVendorQuotations.createdAt, + updatedAt: techSalesVendorQuotations.updatedAt, + createdBy: techSalesVendorQuotations.createdBy, + updatedBy: techSalesVendorQuotations.updatedBy, + // RFQ 정보 + rfqCode: techSalesRfqs.rfqCode, + materialCode: techSalesRfqs.materialCode, + dueDate: techSalesRfqs.dueDate, + rfqStatus: techSalesRfqs.status, + // 아이템 정보 + itemName: items.itemName, + // 프로젝트 정보 (JSON에서 추출) + projNm: sql`${techSalesRfqs.projectSnapshot}->>'projNm'`, + }) + .from(techSalesVendorQuotations) + .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) + .leftJoin(items, eq(techSalesRfqs.itemId, items.id)) + .where(finalWhere) + .orderBy(...orderBy) + .limit(limit) + .offset(offset); + + // 총 개수 조회 + const totalResult = await db + .select({ count: sql`count(*)` }) + .from(techSalesVendorQuotations) + .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) + .leftJoin(items, eq(techSalesRfqs.itemId, items.id)) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + const pageCount = Math.ceil(total / perPage); + + return { data, pageCount, total }; + } catch (err) { + console.error("Error fetching vendor quotations:", err); + return { data: [], pageCount: 0, total: 0 }; + } +} + +/** + * 벤더용 기술영업 견적서 상태별 개수 조회 + */ +export async function getQuotationStatusCounts(vendorId: string) { + unstable_noStore(); + try { + const result = await db + .select({ + status: techSalesVendorQuotations.status, + count: sql`count(*)`, + }) + .from(techSalesVendorQuotations) + .where(eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))) + .groupBy(techSalesVendorQuotations.status); + + return { data: result, error: null }; + } catch (err) { + console.error("Error fetching quotation status counts:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 벤더 견적 승인 (벤더 선택) + */ +export async function acceptTechSalesVendorQuotation(quotationId: number) { + try { + const result = await db.transaction(async (tx) => { + // 1. 선택된 견적 정보 조회 + const selectedQuotation = await tx + .select() + .from(techSalesVendorQuotations) + .where(eq(techSalesVendorQuotations.id, quotationId)) + .limit(1) + + if (selectedQuotation.length === 0) { + throw new Error("견적을 찾을 수 없습니다") + } + + const quotation = selectedQuotation[0] + + // 2. 선택된 견적을 Accepted로 변경 + await tx + .update(techSalesVendorQuotations) + .set({ + status: "Accepted", + acceptedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(techSalesVendorQuotations.id, quotationId)) + + // 3. 같은 RFQ의 다른 견적들을 Rejected로 변경 + await tx + .update(techSalesVendorQuotations) + .set({ + status: "Rejected", + rejectionReason: "다른 벤더가 선택됨", + updatedAt: new Date(), + }) + .where( + and( + eq(techSalesVendorQuotations.rfqId, quotation.rfqId), + ne(techSalesVendorQuotations.id, quotationId), + eq(techSalesVendorQuotations.status, "Submitted") + ) + ) + + // 4. RFQ 상태를 Closed로 변경 + await tx + .update(techSalesRfqs) + .set({ + status: "Closed", + updatedAt: new Date(), + }) + .where(eq(techSalesRfqs.id, quotation.rfqId)) + + return quotation + }) + + // 캐시 무효화 + revalidateTag("techSalesVendorQuotations") + revalidateTag(`techSalesRfq-${result.rfqId}`) + revalidateTag("techSalesRfqs") + + return { success: true, data: result } + } catch (error) { + console.error("벤더 견적 승인 오류:", error) + return { + success: false, + error: error instanceof Error ? error.message : "벤더 견적 승인에 실패했습니다" + } + } +} + +/** + * 기술영업 벤더 견적 거절 + */ +export async function rejectTechSalesVendorQuotation(quotationId: number, rejectionReason?: string) { + try { + const result = await db + .update(techSalesVendorQuotations) + .set({ + status: "Rejected", + rejectionReason: rejectionReason || "기술영업 담당자에 의해 거절됨", + updatedAt: new Date(), + }) + .where(eq(techSalesVendorQuotations.id, quotationId)) + .returning() + + if (result.length === 0) { + throw new Error("견적을 찾을 수 없습니다") + } + + // 캐시 무효화 + revalidateTag("techSalesVendorQuotations") + revalidateTag(`techSalesRfq-${result[0].rfqId}`) + + return { success: true, data: result[0] } + } catch (error) { + console.error("벤더 견적 거절 오류:", error) + return { + success: false, + error: error instanceof Error ? error.message : "벤더 견적 거절에 실패했습니다" + } + } +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/README.md b/lib/techsales-rfq/table/README.md new file mode 100644 index 00000000..74d0005f --- /dev/null +++ b/lib/techsales-rfq/table/README.md @@ -0,0 +1,41 @@ + +# 기술영업 RFQ + +1. 마스터 테이블 +---컬럼--- +상태 +견적프로젝트 이름 +rfqCode (RFQ-YYYY-001) +프로젝트 상세보기 액션컬럼 >> 다이얼로그로 해당 프로젝트 정보 보여줌. (SHI/벤더 동일) + +- 견적 프로젝트명 +- 척수 +- 선주명 +- 선급코드(선급명) +- 선종명 +- 선형명 +- 시리즈 상세보기 >> 시리즈별 K/L 연도분기 >> 2026.2Q 형식 +dueDate (마감일) +sentDate (발송일) +sentBy (발송자) +createdBy (생성자) +updatedBy (수정자) +createdAt (생성일) +updatedAt (수정일) +첨부파일 첨부 테이블 +취소 이유 (삼중이 취소했을 때) +데이터 없으면 취소하기 버튼으로 보여주기. +코멘트 액션컬럼 +---컬럼--- + +2. 디테일 테이블 +디테일 테이블에서는 마스터 테이블의 레코드를 선택했을 때 해당 레코드의 상세내역을 보여줌. +여기서는 벤더별 rfq 송신 및 현황 확인을 응답을 확인할 수 있도록, 발주용 견적과 유사하게 처리 +---컬럼--- +벤더명 +상태 +응답 (가격) +발송일 +발송자 +응답일 +응답자 diff --git a/lib/techsales-rfq/table/create-rfq-dialog.tsx b/lib/techsales-rfq/table/create-rfq-dialog.tsx new file mode 100644 index 00000000..cc652b44 --- /dev/null +++ b/lib/techsales-rfq/table/create-rfq-dialog.tsx @@ -0,0 +1,537 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { CalendarIcon } from "lucide-react" +import { format } from "date-fns" +import { ko } from "date-fns/locale" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import * as z from "zod" +import { EstimateProjectSelector } from "@/components/BidProjectSelector" +import { type Project } from "@/lib/rfqs/service" +import { createTechSalesRfq } from "@/lib/techsales-rfq/service" +import { useSession } from "next-auth/react" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { cn } from "@/lib/utils" +import { ScrollArea } from "@/components/ui/scroll-area" + +// 실제 데이터 서비스 import +import { + getShipbuildingItemsByWorkType, + searchShipbuildingItems, + getWorkTypes, + type ShipbuildingItem, + type WorkType +} from "@/lib/items-tech/service" + +// 유효성 검증 스키마 - 자재코드(item_code) 배열로 변경 +const createRfqSchema = z.object({ + biddingProjectId: z.number({ + required_error: "프로젝트를 선택해주세요.", + }), + materialCodes: z.array(z.string()).min(1, { + message: "적어도 하나의 자재코드를 선택해야 합니다.", + }), + dueDate: z.date({ + required_error: "마감일을 선택해주세요.", + }), +}) + +// 폼 데이터 타입 +type CreateRfqFormValues = z.infer + +// 공종 타입 정의 +interface WorkTypeOption { + code: WorkType + name: string + description: string +} + +interface CreateRfqDialogProps { + onCreated?: () => void; +} + +export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { + const { data: session } = useSession() + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState(null) + + // 검색 및 필터링 상태 + const [itemSearchQuery, setItemSearchQuery] = React.useState("") + const [selectedWorkType, setSelectedWorkType] = React.useState(null) + const [selectedItems, setSelectedItems] = React.useState([]) + const [isSearchingItems, setIsSearchingItems] = React.useState(false) + + // 데이터 상태 + const [workTypes, setWorkTypes] = React.useState([]) + const [availableItems, setAvailableItems] = React.useState([]) + const [isLoadingItems, setIsLoadingItems] = React.useState(false) + + // RFQ 생성 폼 + const form = useForm({ + resolver: zodResolver(createRfqSchema), + defaultValues: { + biddingProjectId: undefined, + materialCodes: [], + dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후 + } + }) + + // 공종 목록 로드 + React.useEffect(() => { + const loadWorkTypes = async () => { + const types = await getWorkTypes() + setWorkTypes(types) + } + loadWorkTypes() + }, []) + + // 아이템 데이터 로드 + const loadItems = React.useCallback(async () => { + setIsLoadingItems(true) + try { + let result + if (itemSearchQuery.trim()) { + result = await searchShipbuildingItems(itemSearchQuery, selectedWorkType || undefined) + } else { + result = await getShipbuildingItemsByWorkType(selectedWorkType || undefined) + } + + if (result.error) { + toast.error(`아이템 로드 오류: ${result.error}`) + setAvailableItems([]) + } else { + setAvailableItems(result.data || []) + } + } catch (error) { + console.error("아이템 로드 오류:", error) + toast.error("아이템을 불러오는 중 오류가 발생했습니다") + setAvailableItems([]) + } finally { + setIsLoadingItems(false) + } + }, [itemSearchQuery, selectedWorkType]) + + // 아이템 검색 디바운스 + React.useEffect(() => { + setIsSearchingItems(true) + const timer = setTimeout(() => { + loadItems() + setIsSearchingItems(false) + }, 300) + + return () => clearTimeout(timer) + }, [loadItems]) + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: Project) => { + setSelectedProject(project) + form.setValue("biddingProjectId", project.id) + // 선택 초기화 + setSelectedItems([]) + form.setValue("materialCodes", []) + } + + // 아이템 선택/해제 처리 + const handleItemToggle = (item: ShipbuildingItem) => { + const isSelected = selectedItems.some(selected => selected.id === item.id) + + if (isSelected) { + // 아이템 선택 해제 + const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id) + setSelectedItems(newSelectedItems) + form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) + } else { + // 아이템 선택 추가 + const newSelectedItems = [...selectedItems, item] + setSelectedItems(newSelectedItems) + form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) + } + } + + // 아이템 제거 처리 + const handleRemoveItem = (itemId: number) => { + const newSelectedItems = selectedItems.filter(item => item.id !== itemId) + setSelectedItems(newSelectedItems) + form.setValue("materialCodes", newSelectedItems.map(item => item.itemCode)) + } + + // RFQ 생성 함수 + const handleCreateRfq = async (data: CreateRfqFormValues) => { + try { + setIsProcessing(true) + + // 사용자 인증 확인 + if (!session?.user?.id) { + throw new Error("로그인이 필요합니다") + } + + // 자재코드(item_code) 배열을 materialGroupCodes로 전달 + const result = await createTechSalesRfq({ + biddingProjectId: data.biddingProjectId, + materialGroupCodes: data.materialCodes, // item_code를 자재코드로 사용 + createdBy: Number(session.user.id), + dueDate: data.dueDate, + }) + + if (result.error) { + throw new Error(result.error) + } + + // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시 + toast.success(`${result.data?.length || 0}개의 RFQ가 성공적으로 생성되었습니다`) + setIsDialogOpen(false) + form.reset({ + biddingProjectId: undefined, + materialCodes: [], + dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후로 재설정 + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setAvailableItems([]) + + // 생성 후 콜백 실행 + if (onCreated) { + onCreated() + } + + } catch (error) { + console.error("RFQ 생성 오류:", error) + toast.error(`RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } finally { + setIsProcessing(false) + } + } + + return ( + { + setIsDialogOpen(open) + if (!open) { + form.reset({ + biddingProjectId: undefined, + materialCodes: [], + dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14일 후로 재설정 + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setAvailableItems([]) + } + }} + > + + + + + + RFQ 생성 + + +
+
+ + {/* 프로젝트 선택 */} + ( + + 입찰 프로젝트 + + + + + + )} + /> + + + + {/* 마감일 설정 */} + ( + + 마감일 + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + 벤더가 견적을 제출해야 하는 마감일입니다. + + + + )} + /> + + + + {!selectedProject ? ( +
+ 먼저 프로젝트를 선택해주세요 +
+ ) : ( +
+ {/* 아이템 선택 영역 */} +
+
+ 조선 아이템 선택 + + 공종별 아이템을 선택하세요 + +
+ + {/* 아이템 검색 및 필터 */} +
+
+
+ + setItemSearchQuery(e.target.value)} + className="pl-8 pr-8" + /> + {itemSearchQuery && ( + + )} + {isSearchingItems && ( + + )} +
+ + {/* 공종 필터 */} + + + + + + setSelectedWorkType(null)} + > + 전체 공종 + + {workTypes.map(workType => ( + setSelectedWorkType(workType.code)} + > + {workType.name} + + ))} + + +
+
+ + {/* 아이템 목록 */} +
+ +
+ {isLoadingItems ? ( +
+ + 아이템을 불러오는 중... +
+ ) : availableItems.length > 0 ? ( + availableItems.map((item) => { + const isSelected = selectedItems.some(selected => selected.id === item.id) + return ( +
handleItemToggle(item)} + > +
+ {isSelected ? ( + + ) : ( + + )} +
+
+ {item.itemList || item.itemName} +
+
+ {item.itemCode} • {item.description || '설명 없음'} +
+
+ 공종: {item.workType} • 선종: {item.shipTypes} +
+
+
+
+ ) + }) + ) : ( +
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} +
+ )} +
+
+
+ + {/* 선택된 아이템 목록 */} + ( + + 선택된 아이템 ({selectedItems.length}개) +
+ {selectedItems.length > 0 ? ( +
+ {selectedItems.map((item) => ( + + {item.itemList || item.itemName} ({item.itemCode}) + handleRemoveItem(item.id)} + /> + + ))} +
+ ) : ( +
+ 선택된 아이템이 없습니다 +
+ )} +
+ +
+ )} + /> +
+
+ )} + + {/* 안내 메시지 */} + {selectedProject && ( +
+

• 공종별 조선 아이템을 선택하세요.

+

• 선택된 아이템의 자재코드(item_code)별로 개별 RFQ가 생성됩니다.

+

• 아이템 코드가 자재 그룹 코드로 사용됩니다.

+

• 마감일은 벤더가 견적을 제출해야 하는 날짜입니다.

+
+ )} + +
+ + +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx new file mode 100644 index 00000000..b66f4d77 --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx @@ -0,0 +1,357 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useCallback } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { Check, X, Search, Loader2 } from "lucide-react" +import { useSession } from "next-auth/react" + +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { addVendorsToTechSalesRfq } from "@/lib/techsales-rfq/service" +import { searchVendors } from "@/lib/vendors/service" + +// 폼 유효성 검증 스키마 - 간단화 +const vendorFormSchema = z.object({ + vendorIds: z.array(z.number()).min(1, "최소 하나의 벤더를 선택해주세요"), +}) + +type VendorFormValues = z.infer + +// 기술영업 RFQ 타입 정의 +type TechSalesRfq = { + id: number + rfqCode: string | null + status: string + [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any +} + +// 벤더 검색 결과 타입 (searchVendors 함수 반환 타입과 일치) +type VendorSearchResult = { + id: number + vendorName: string + vendorCode: string | null + status: string + country: string | null +} + +interface AddVendorDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedRfq: TechSalesRfq | null + onSuccess?: () => void + existingVendorIds?: number[] +} + +export function AddVendorDialog({ + open, + onOpenChange, + selectedRfq, + onSuccess, + existingVendorIds = [], +}: AddVendorDialogProps) { + const { data: session } = useSession() + const [isSubmitting, setIsSubmitting] = useState(false) + const [searchTerm, setSearchTerm] = useState("") + const [searchResults, setSearchResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + const [hasSearched, setHasSearched] = useState(false) + // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지 + const [selectedVendorData, setSelectedVendorData] = useState([]) + + const form = useForm({ + resolver: zodResolver(vendorFormSchema), + defaultValues: { + vendorIds: [], + }, + }) + + const selectedVendorIds = form.watch("vendorIds") + + // 검색 함수 (디바운스 적용) + const searchVendorsDebounced = useCallback( + async (term: string) => { + if (!term.trim()) { + setSearchResults([]) + setHasSearched(false) + return + } + + setIsSearching(true) + try { + const results = await searchVendors(term, 100) + // 이미 추가된 벤더 제외 + const filteredResults = results.filter(vendor => !existingVendorIds.includes(vendor.id)) + setSearchResults(filteredResults) + setHasSearched(true) + } catch (error) { + console.error("벤더 검색 오류:", error) + toast.error("벤더 검색 중 오류가 발생했습니다") + setSearchResults([]) + } finally { + setIsSearching(false) + } + }, + [existingVendorIds] + ) + + // 검색어 변경 시 디바운스 적용 + useEffect(() => { + const timer = setTimeout(() => { + searchVendorsDebounced(searchTerm) + }, 300) + + return () => clearTimeout(timer) + }, [searchTerm, searchVendorsDebounced]) + + // 벤더 선택/해제 핸들러 + const handleVendorToggle = (vendor: VendorSearchResult) => { + const currentIds = form.getValues("vendorIds") + const isSelected = currentIds.includes(vendor.id) + + if (isSelected) { + // 선택 해제 + const newIds = currentIds.filter(id => id !== vendor.id) + const newSelectedData = selectedVendorData.filter(v => v.id !== vendor.id) + form.setValue("vendorIds", newIds, { shouldValidate: true }) + setSelectedVendorData(newSelectedData) + } else { + // 선택 추가 + const newIds = [...currentIds, vendor.id] + const newSelectedData = [...selectedVendorData, vendor] + form.setValue("vendorIds", newIds, { shouldValidate: true }) + setSelectedVendorData(newSelectedData) + } + } + + // 선택된 벤더 제거 핸들러 + const handleRemoveVendor = (vendorId: number) => { + const currentIds = form.getValues("vendorIds") + const newIds = currentIds.filter(id => id !== vendorId) + const newSelectedData = selectedVendorData.filter(v => v.id !== vendorId) + form.setValue("vendorIds", newIds, { shouldValidate: true }) + setSelectedVendorData(newSelectedData) + } + + // 폼 제출 핸들러 + async function onSubmit(values: VendorFormValues) { + if (!selectedRfq) { + toast.error("선택된 RFQ가 없습니다") + return + } + + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + try { + setIsSubmitting(true) + + // 서비스 함수 호출 + const result = await addVendorsToTechSalesRfq({ + rfqId: selectedRfq.id, + vendorIds: values.vendorIds, + createdBy: Number(session.user.id), + }) + + if (result.error) { + toast.error(result.error) + } else { + const successMessage = `${result.successCount}개의 벤더가 성공적으로 추가되었습니다` + const errorMessage = result.errorCount && result.errorCount > 0 ? ` (${result.errorCount}개 실패)` : "" + toast.success(successMessage + errorMessage) + + onOpenChange(false) + form.reset() + setSearchTerm("") + setSearchResults([]) + setHasSearched(false) + setSelectedVendorData([]) + onSuccess?.() + } + } catch (error) { + console.error("벤더 추가 오류:", error) + toast.error("벤더 추가 중 오류가 발생했습니다") + } finally { + setIsSubmitting(false) + } + } + + // 다이얼로그 닫기 시 폼 리셋 + React.useEffect(() => { + if (!open) { + form.reset() + setSearchTerm("") + setSearchResults([]) + setHasSearched(false) + setSelectedVendorData([]) + } + }, [open, form]) + + return ( + + + {/* 헤더 */} + + 벤더 추가 + + {selectedRfq ? ( + <> + {selectedRfq.rfqCode} RFQ에 벤더를 추가합니다. + + ) : ( + "RFQ에 벤더를 추가합니다." + )} + + + + {/* 콘텐츠 */} +
+
+ + {/* 벤더 검색 필드 */} +
+ +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> + {isSearching && ( + + )} +
+
+ + {/* 검색 결과 */} + {hasSearched && ( +
+
+ 검색 결과 ({searchResults.length}개) +
+ +
+ {searchResults.length > 0 ? ( + searchResults.map((vendor) => ( +
handleVendorToggle(vendor)} + > +
+ +
+
{vendor.vendorName}
+
+ {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} +
+
+
+
+ )) + ) : ( +
+ 검색 결과가 없습니다 +
+ )} +
+
+
+ )} + + {/* 검색 안내 메시지 */} + {!hasSearched && !searchTerm && ( +
+ 벤더명 또는 벤더코드를 입력하여 검색해주세요 +
+ )} + + {/* 선택된 벤더 목록 - 하단에 항상 표시 */} + ( + +
+ 선택된 벤더 ({selectedVendorData.length}개) +
+ {selectedVendorData.length > 0 ? ( +
+ {selectedVendorData.map((vendor) => ( + + {vendor.vendorName} ({vendor.vendorCode || 'N/A'}) + handleRemoveVendor(vendor.id)} + /> + + ))} +
+ ) : ( +
+ 선택된 벤더가 없습니다 +
+ )} +
+
+ +
+ )} + /> + + {/* 안내 메시지 */} +
+ {/*

• 검색은 ACTIVE 상태의 벤더만 대상으로 합니다.

*/} +

• 선택된 벤더들은 Draft 상태로 추가됩니다.

+

• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.

+

• 이미 추가된 벤더는 검색 결과에서 체크됩니다.

+
+ + +
+ + {/* 푸터 */} + + + + +
+
+ ) +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx new file mode 100644 index 00000000..d7e3403b --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { type RfqDetailView } from "./rfq-detail-column" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { deleteRfqDetail } from "@/lib/procurement-rfqs/services" + + +interface DeleteRfqDetailDialogProps + extends React.ComponentPropsWithoutRef { + detail: RfqDetailView | null + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteVendorDialog({ + detail, + showTrigger = true, + onSuccess, + ...props +}: DeleteRfqDetailDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + if (!detail) return + + startDeleteTransition(async () => { + try { + const result = await deleteRfqDetail(detail.id) + + if (!result.success) { + toast.error(result.message || "삭제 중 오류가 발생했습니다") + return + } + + props.onOpenChange?.(false) + toast.success("RFQ 벤더 정보가 삭제되었습니다") + onSuccess?.() + } catch (error) { + console.error("RFQ 벤더 삭제 오류:", error) + toast.error("삭제 중 오류가 발생했습니다") + } + }) + } + + if (isDesktop) { + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx new file mode 100644 index 00000000..c4a7edde --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx @@ -0,0 +1,291 @@ +"use client" + +import * as React from "react" +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { formatDate } from "@/lib/utils" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Ellipsis, MessageCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +export interface DataTableRowAction { + row: Row; + type: "delete" | "update" | "communicate"; +} + +// 벤더 견적 데이터 타입 정의 +export interface RfqDetailView { + id: number + rfqId: number + vendorId?: number | null + vendorName: string | null + vendorCode: string | null + totalPrice: string | number | null + currency: string | null + validUntil: Date | null + status: string | null + remark: string | null + submittedAt: Date | null + acceptedAt: Date | null + rejectionReason: string | null + createdAt: Date | null + updatedAt: Date | null + createdByName: string | null +} + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction | null> + >; + unreadMessages?: Record; // 읽지 않은 메시지 개수 +} + +export function getRfqDetailColumns({ + setRowAction, + unreadMessages = {} +}: GetColumnsProps): ColumnDef[] { + return [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="모두 선택" + /> + ), + cell: ({ row }) => { + const status = row.original.status; + const isDraft = status === "Draft"; + + return ( + row.toggleSelected(!!value)} + disabled={!isDraft} + aria-label="행 선택" + className={!isDraft ? "opacity-50 cursor-not-allowed" : ""} + /> + ); + }, + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const status = row.getValue("status") as string; + // 상태에 따른 배지 색상 설정 + let variant: "default" | "secondary" | "outline" | "destructive" = "outline"; + + if (status === "Submitted") { + variant = "default"; // 제출됨 - 기본 색상 + } else if (status === "Accepted") { + variant = "secondary"; // 승인됨 - 보조 색상 + } else if (status === "Rejected") { + variant = "destructive"; // 거부됨 - 위험 색상 + } + + return ( + {status || "Draft"} + ); + }, + meta: { + excelHeader: "견적 상태" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("vendorCode")}
, + meta: { + excelHeader: "벤더 코드" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("vendorName")}
, + meta: { + excelHeader: "벤더명" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "totalPrice", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("totalPrice") as string | number | null; + const currency = row.getValue("currency") as string | null; + + if (value === null || value === undefined) return "-"; + + // 숫자로 변환 시도 + const numValue = typeof value === 'string' ? parseFloat(value) : value; + + return ( +
+ {isNaN(numValue) ? value : numValue.toLocaleString()} {currency} +
+ ); + }, + meta: { + excelHeader: "견적 금액" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "currency", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("currency")}
, + meta: { + excelHeader: "통화" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "validUntil", + header: ({ column }) => ( + + ), + cell: ({ cell }) => { + const value = cell.getValue() as Date | null; + return value ? formatDate(value, "KR") : "-"; + }, + meta: { + excelHeader: "유효기간" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "submittedAt", + header: ({ column }) => ( + + ), + cell: ({ cell }) => { + const value = cell.getValue() as Date | null; + return value ? formatDate(value, "KR") : "-"; + }, + meta: { + excelHeader: "제출일" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "createdByName", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("createdByName")}
, + meta: { + excelHeader: "등록자" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "remark", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("remark") || "-"}
, + meta: { + excelHeader: "비고" + }, + enableResizing: true, + size: 200, + }, + { + id: "actions", + header: () =>
동작
, + cell: function Cell({ row }) { + const vendorId = row.original.vendorId; + const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0; + + return ( +
+ {/* 커뮤니케이션 버튼 */} +
+ + {unreadCount > 0 && ( + + {unreadCount > 9 ? '9+' : unreadCount} + + )} +
+ + {/* 기존 드롭다운 메뉴 */} + + + + + + setRowAction({ row, type: "update" })} + > + 벤더 수정 + + setRowAction({ row, type: "delete" })} + className="text-destructive focus:text-destructive" + > + 벤더 제거 + + + +
+ ); + }, + enableResizing: false, + size: 80, + }, + ]; +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx new file mode 100644 index 00000000..4f8ac37b --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -0,0 +1,654 @@ +"use client" + +import * as React from "react" +import { useEffect, useState, useCallback, useMemo } from "react" +import { + DataTableRowAction, + getRfqDetailColumns, + RfqDetailView +} from "./rfq-detail-column" +import { toast } from "sonner" + +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Loader2, UserPlus, BarChart2, Send, Trash2, MessageCircle } from "lucide-react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { AddVendorDialog } from "./add-vendor-dialog" +import { DeleteVendorDialog } from "./delete-vendor-dialog" +import { UpdateVendorSheet } from "./update-vendor-sheet" +import { VendorCommunicationDrawer } from "./vendor-communication-drawer" +import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" + +// 기본적인 RFQ 타입 정의 +interface TechSalesRfq { + id: number + rfqCode: string | null + status: string + materialCode?: string | null + itemName?: string | null + remark?: string | null + rfqSendDate?: Date | null + dueDate?: Date | null + createdByName?: string | null + // 필요에 따라 다른 필드들 추가 + [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any +} + +// 프로퍼티 정의 +interface RfqDetailTablesProps { + selectedRfq: TechSalesRfq | null + maxHeight?: string | number +} + +// 데이터 타입 정의 +interface Vendor { + id: number; + vendorName: string; + vendorCode: string; + // 기타 필요한 벤더 속성들 +} + +interface Currency { + code: string; + name: string; +} + +interface PaymentTerm { + code: string; + description: string; +} + +interface Incoterm { + code: string; + description: string; +} + +export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) { + // console.log("selectedRfq", selectedRfq) + + // 상태 관리 + const [isLoading, setIsLoading] = useState(false) + const [details, setDetails] = useState([]) + const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) + const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) + const [selectedDetail, setSelectedDetail] = React.useState(null) + + const [vendors, setVendors] = React.useState([]) + const [currencies, setCurrencies] = React.useState([]) + const [paymentTerms, setPaymentTerms] = React.useState([]) + const [incoterms, setIncoterms] = React.useState([]) + const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) + + const [rowAction, setRowAction] = React.useState | null>(null) + + // 벤더 커뮤니케이션 상태 관리 + const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) + const [selectedVendor, setSelectedVendor] = useState(null) + + // 읽지 않은 메시지 개수 + const [unreadMessages, setUnreadMessages] = useState>({}) + + // 견적 비교 다이얼로그 상태 관리 + const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false) + + // 테이블 선택 상태 관리 + const [selectedRows, setSelectedRows] = useState([]) + const [isSendingRfq, setIsSendingRfq] = useState(false) + const [isDeletingVendors, setIsDeletingVendors] = useState(false) + + // selectedRfq ID 메모이제이션 (객체 참조 변경 방지) + const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id]) + + // existingVendorIds 메모이제이션 + const existingVendorIds = useMemo(() => { + return details.map(detail => Number(detail.vendorId)).filter(Boolean); + }, [details]); + + // 읽지 않은 메시지 로드 함수 메모이제이션 + const loadUnreadMessages = useCallback(async () => { + if (!selectedRfqId) return; + + try { + // TODO: 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현 필요 + // const unreadData = await fetchUnreadMessages(selectedRfqId); + // setUnreadMessages(unreadData); + setUnreadMessages({}); + } catch (error) { + console.error("읽지 않은 메시지 로드 오류:", error); + } + }, [selectedRfqId]); + + // 데이터 새로고침 함수 메모이제이션 + const handleRefreshData = useCallback(async () => { + if (!selectedRfqId) return + + try { + // 실제 벤더 견적 데이터 다시 로딩 + const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service") + + const result = await getTechSalesVendorQuotationsWithJoin({ + rfqId: selectedRfqId, + page: 1, + perPage: 1000, + }) + + // 데이터 변환 + const transformedData = result.data?.map(item => ({ + ...item, + detailId: item.id, + rfqId: selectedRfqId, + rfqCode: selectedRfq?.rfqCode || null, + vendorId: item.vendorId ? Number(item.vendorId) : undefined, + })) || [] + + setDetails(transformedData) + + // 읽지 않은 메시지 개수 업데이트 + await loadUnreadMessages(); + + toast.success("데이터를 성공적으로 새로고침했습니다") + } catch (error) { + console.error("데이터 새로고침 오류:", error) + toast.error("데이터를 새로고침하는 중 오류가 발생했습니다") + } + }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages]) + + // 벤더 추가 핸들러 메모이제이션 + const handleAddVendor = useCallback(async () => { + try { + setIsAdddialogLoading(true) + + // TODO: 기술영업용 벤더, 통화, 지불조건, 인코텀즈 데이터 로드 함수 구현 필요 + // const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ + // fetchVendors(), + // fetchCurrencies(), + // fetchPaymentTerms(), + // fetchIncoterms() + // ]) + + // 임시 데이터 + setVendors([]) + setCurrencies([]) + setPaymentTerms([]) + setIncoterms([]) + + setVendorDialogOpen(true) + } catch (error) { + console.error("데이터 로드 오류:", error) + toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다") + } finally { + setIsAdddialogLoading(false) + } + }, []) + + // RFQ 발송 핸들러 메모이제이션 + const handleSendRfq = useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("발송할 벤더를 선택해주세요."); + return; + } + + if (!selectedRfqId) { + toast.error("선택된 RFQ가 없습니다."); + return; + } + + try { + setIsSendingRfq(true); + + // 기술영업 RFQ 발송 서비스 함수 호출 + const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean); + const { sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service"); + + const result = await sendTechSalesRfqToVendors({ + rfqId: selectedRfqId, + vendorIds: vendorIds as number[] + }); + + if (result.success) { + toast.success(result.message || `${selectedRows.length}개 벤더에게 RFQ가 발송되었습니다.`); + } else { + toast.error(result.message || "RFQ 발송 중 오류가 발생했습니다."); + } + + // 선택 해제 + setSelectedRows([]); + + // 데이터 새로고침 + await handleRefreshData(); + + } catch (error) { + console.error("RFQ 발송 오류:", error); + toast.error("RFQ 발송 중 오류가 발생했습니다."); + } finally { + setIsSendingRfq(false); + } + }, [selectedRows, selectedRfqId, handleRefreshData]); + + // 벤더 삭제 핸들러 메모이제이션 + const handleDeleteVendors = useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("삭제할 벤더를 선택해주세요."); + return; + } + + if (!selectedRfqId) { + toast.error("선택된 RFQ가 없습니다."); + return; + } + + try { + setIsDeletingVendors(true); + + const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[]; + + if (vendorIds.length === 0) { + toast.error("유효한 벤더 ID가 없습니다."); + return; + } + + // 서비스 함수 호출 + const { removeVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service"); + + const result = await removeVendorsFromTechSalesRfq({ + rfqId: selectedRfqId, + vendorIds: vendorIds + }); + + if (result.error) { + toast.error(result.error); + } else { + const successMessage = `${result.successCount}개의 벤더가 성공적으로 삭제되었습니다`; + const errorMessage = result.errorCount && result.errorCount > 0 ? ` (${result.errorCount}개 실패)` : ""; + toast.success(successMessage + errorMessage); + } + + // 선택 해제 + setSelectedRows([]); + + // 데이터 새로고침 + await handleRefreshData(); + + } catch (error) { + console.error("벤더 삭제 오류:", error); + toast.error("벤더 삭제 중 오류가 발생했습니다."); + } finally { + setIsDeletingVendors(false); + } + }, [selectedRows, selectedRfqId, handleRefreshData]); + + // 견적 비교 다이얼로그 열기 핸들러 메모이제이션 + const handleOpenComparisonDialog = useCallback(() => { + // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인 + const hasSubmittedQuotations = details.some(detail => + detail.status === "Submitted" // RfqDetailView의 실제 필드 사용 + ); + + if (!hasSubmittedQuotations) { + toast.warning("제출된 견적이 없습니다."); + return; + } + + setComparisonDialogOpen(true); + }, [details]) + + // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션) + const columns = useMemo(() => + getRfqDetailColumns({ + setRowAction, + unreadMessages + }), [unreadMessages]) + + // 필터 필드 정의 (메모이제이션) + const advancedFilterFields = useMemo( + () => [ + { + id: "vendorName", + label: "벤더명", + type: "text", + }, + { + id: "vendorCode", + label: "벤더 코드", + type: "text", + }, + { + id: "currency", + label: "통화", + type: "text", + }, + ], + [] + ) + + // 계산된 값들 메모이제이션 + const totalUnreadMessages = useMemo(() => + Object.values(unreadMessages).reduce((sum, count) => sum + count, 0), + [unreadMessages] + ); + + const vendorsWithQuotations = useMemo(() => + details.filter(detail => detail.status === "Submitted").length, + [details] + ); + + // RFQ ID가 변경될 때 데이터 로드 + useEffect(() => { + async function loadRfqDetails() { + if (!selectedRfqId) { + setDetails([]) + return + } + + try { + setIsLoading(true) + + // 실제 벤더 견적 데이터 로딩 + const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service") + + const result = await getTechSalesVendorQuotationsWithJoin({ + rfqId: selectedRfqId, + page: 1, + perPage: 1000, // 모든 데이터 가져오기 + }) + + // 데이터 변환 (procurement 패턴에 맞게) + const transformedData = result.data?.map(item => ({ + ...item, + detailId: item.id, + rfqId: selectedRfqId, + rfqCode: selectedRfq?.rfqCode || null, + vendorId: item.vendorId ? Number(item.vendorId) : undefined, + // 기타 필요한 필드 변환 + })) || [] + + setDetails(transformedData) + + // 읽지 않은 메시지 개수 로드 + await loadUnreadMessages(); + + } catch (error) { + console.error("RFQ 디테일 로드 오류:", error) + setDetails([]) + toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다") + } finally { + setIsLoading(false) + } + } + + loadRfqDetails() + }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages]) + + // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - 메모이제이션된 함수 사용 + useEffect(() => { + if (!selectedRfqId) return; + + const intervalId = setInterval(() => { + loadUnreadMessages(); + }, 60000); // 60초마다 갱신 + + return () => clearInterval(intervalId); + }, [selectedRfqId, loadUnreadMessages]); + + // rowAction 처리 - procurement 패턴 적용 (메모이제이션) + useEffect(() => { + if (!rowAction) return + + const handleRowAction = async () => { + try { + // 통신 액션인 경우 드로어 열기 + if (rowAction.type === "communicate") { + setSelectedVendor(rowAction.row.original); + setCommunicationDrawerOpen(true); + + // 해당 벤더의 읽지 않은 메시지를 0으로 설정 (메시지를 읽은 것으로 간주) + const vendorId = rowAction.row.original.vendorId; + if (vendorId) { + setUnreadMessages(prev => ({ + ...prev, + [vendorId]: 0 + })); + } + + // rowAction 초기화 + setRowAction(null); + return; + } + + // 다른 액션들은 기존과 동일하게 처리 + setIsAdddialogLoading(true); + + // TODO: 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) + // const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ + // fetchVendors(), + // fetchCurrencies(), + // fetchPaymentTerms(), + // fetchIncoterms() + // ]); + + // 임시 데이터 + setVendors([]); + setCurrencies([]); + setPaymentTerms([]); + setIncoterms([]); + + // 이제 데이터가 로드되었으므로 필요한 작업 수행 + if (rowAction.type === "update") { + setSelectedDetail(rowAction.row.original); + setUpdateSheetOpen(true); + } else if (rowAction.type === "delete") { + setSelectedDetail(rowAction.row.original); + setDeleteDialogOpen(true); + } + } catch (error) { + console.error("데이터 로드 오류:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다"); + } finally { + // communicate 타입이 아닌 경우에만 로딩 상태 변경 + if (rowAction && rowAction.type !== "communicate") { + setIsAdddialogLoading(false); + } + } + }; + + handleRowAction(); + }, [rowAction]) + + // 선택된 행 변경 핸들러 메모이제이션 + const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => { + setSelectedRows(selectedRowsData); + }, []); + + // 커뮤니케이션 드로어 변경 핸들러 메모이제이션 + const handleCommunicationDrawerChange = useCallback((open: boolean) => { + setCommunicationDrawerOpen(open); + // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신 + if (!open) loadUnreadMessages(); + }, [loadUnreadMessages]); + + if (!selectedRfq) { + return ( +
+ RFQ를 선택하세요 +
+ ) + } + + // 로딩 중인 경우 + if (isLoading) { + return ( +
+ + + +
+ ) + } + + return ( +
+ {/* 테이블 또는 빈 상태 표시 */} + {details.length > 0 ? ( + +
+
+ {selectedRows.length > 0 && ( + + {selectedRows.length}개 선택됨 + + )} + {totalUnreadMessages > 0 && ( + + 읽지 않은 메시지: {totalUnreadMessages}건 + + )} + {vendorsWithQuotations > 0 && ( + + 견적 제출: {vendorsWithQuotations}개 벤더 + + )} +
+
+ {/* RFQ 발송 버튼 */} + + + {/* 벤더 삭제 버튼 */} + + + {/* 견적 비교 버튼 */} + + + {/* 벤더 추가 버튼 */} + +
+
+
+ ) : ( +
+
+

벤더가 없습니다

+

벤더를 추가하여 RFQ를 시작하세요

+ +
+
+ )} + + {/* 다이얼로그들 */} + + + + + + + {/* 벤더 커뮤니케이션 드로어 */} + + + {/* 견적 비교 다이얼로그 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx b/lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx new file mode 100644 index 00000000..0399f4df --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx @@ -0,0 +1,449 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Checkbox } from "@/components/ui/checkbox" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { RfqDetailView } from "./rfq-detail-column" +import { updateRfqDetail } from "@/lib/procurement-rfqs/services" + +// 폼 유효성 검증 스키마 +const updateRfqDetailSchema = z.object({ + vendorId: z.string().min(1, "벤더를 선택해주세요"), + currency: z.string().min(1, "통화를 선택해주세요"), + paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"), + incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"), + incotermsDetail: z.string().optional(), + deliveryDate: z.string().optional(), + taxCode: z.string().optional(), + placeOfShipping: z.string().optional(), + placeOfDestination: z.string().optional(), + materialPriceRelatedYn: z.boolean().default(false), +}) + +type UpdateRfqDetailFormValues = z.infer + +// 데이터 타입 정의 +interface Vendor { + id: number; + vendorName: string; + vendorCode: string; +} + +interface Currency { + code: string; + name: string; +} + +interface PaymentTerm { + code: string; + description: string; +} + +interface Incoterm { + code: string; + description: string; +} + +interface UpdateRfqDetailSheetProps + extends React.ComponentPropsWithRef { + detail: RfqDetailView | null; + vendors: Vendor[]; + currencies: Currency[]; + paymentTerms: PaymentTerm[]; + incoterms: Incoterm[]; + onSuccess?: () => void; +} + +export function UpdateVendorSheet({ + detail, + vendors, + currencies, + paymentTerms, + incoterms, + onSuccess, + ...props +}: UpdateRfqDetailSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [vendorOpen, setVendorOpen] = React.useState(false) + + const form = useForm({ + resolver: zodResolver(updateRfqDetailSchema), + defaultValues: { + vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "", + currency: detail?.currency || "", + paymentTermsCode: detail?.paymentTermsCode || "", + incotermsCode: detail?.incotermsCode || "", + incotermsDetail: detail?.incotermsDetail || "", + deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", + taxCode: detail?.taxCode || "", + placeOfShipping: detail?.placeOfShipping || "", + placeOfDestination: detail?.placeOfDestination || "", + materialPriceRelatedYn: detail?.materialPriceRelatedYn || false, + }, + }) + + // detail이 변경될 때 form 값 업데이트 + React.useEffect(() => { + if (detail) { + const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id + + form.reset({ + vendorId: vendorId ? String(vendorId) : "", + currency: detail.currency || "", + paymentTermsCode: detail.paymentTermsCode || "", + incotermsCode: detail.incotermsCode || "", + incotermsDetail: detail.incotermsDetail || "", + deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", + taxCode: detail.taxCode || "", + placeOfShipping: detail.placeOfShipping || "", + placeOfDestination: detail.placeOfDestination || "", + materialPriceRelatedYn: detail.materialPriceRelatedYn || false, + }) + } + }, [detail, form, vendors]) + + function onSubmit(values: UpdateRfqDetailFormValues) { + if (!detail) return + + startUpdateTransition(async () => { + try { + const result = await updateRfqDetail(detail.detailId, values) + + if (!result.success) { + toast.error(result.message || "수정 중 오류가 발생했습니다") + return + } + + props.onOpenChange?.(false) + toast.success("RFQ 벤더 정보가 수정되었습니다") + onSuccess?.() + } catch (error) { + console.error("RFQ 벤더 수정 오류:", error) + toast.error("수정 중 오류가 발생했습니다") + } + }) + } + + return ( + + + + RFQ 벤더 정보 수정 + + 벤더 정보를 수정하고 저장하세요 + + + +
+ + {/* 검색 가능한 벤더 선택 필드 */} + ( + + 벤더 * + + + + + + + + + + 검색 결과가 없습니다 + + + {vendors.map((vendor) => ( + { + form.setValue("vendorId", String(vendor.id), { + shouldValidate: true, + }) + setVendorOpen(false) + }} + > + + {vendor.vendorName} ({vendor.vendorCode}) + + ))} + + + + + + + + )} + /> + + ( + + 통화 * + + + + )} + /> + +
+ ( + + 지불 조건 * + + + + )} + /> + + ( + + 인코텀즈 * + + + + )} + /> +
+ + ( + + 인코텀즈 세부사항 + + + + + + )} + /> + +
+ ( + + 납품 예정일 + + + + + + )} + /> + + ( + + 세금 코드 + + + + + + )} + /> +
+ +
+ ( + + 선적지 + + + + + + )} + /> + + ( + + 도착지 + + + + + + )} + /> +
+ + ( + + + + +
+ 자재 가격 관련 여부 +
+
+ )} + /> + + +
+ + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx new file mode 100644 index 00000000..51ef7b38 --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx @@ -0,0 +1,521 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useRef } from "react" +import { RfqDetailView } from "./rfq-detail-column" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { toast } from "sonner" +import { + Send, + Paperclip, + DownloadCloud, + File, + FileText, + Image as ImageIcon, + AlertCircle, + X +} from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { formatDateTime } from "@/lib/utils" +import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트 +import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/services" + +// 타입 정의 +interface Comment { + id: number; + rfqId: number; + vendorId: number | null // null 허용으로 변경 + userId?: number | null // null 허용으로 변경 + content: string; + isVendorComment: boolean | null; // null 허용으로 변경 + createdAt: Date; + updatedAt: Date; + userName?: string | null // null 허용으로 변경 + vendorName?: string | null // null 허용으로 변경 + attachments: Attachment[]; + isRead: boolean | null // null 허용으로 변경 +} + +interface Attachment { + id: number; + fileName: string; + fileSize: number; + fileType: string; + filePath: string; + uploadedAt: Date; +} + +// 프롭스 정의 +interface VendorCommunicationDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedRfq: { + id: number; + rfqCode: string | null; + status: string; + [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any + } | null; + selectedVendor: RfqDetailView | null; + onSuccess?: () => void; +} + +async function sendComment(params: { + rfqId: number; + vendorId: number; + content: string; + attachments?: File[]; +}): Promise { + try { + // 폼 데이터 생성 (파일 첨부를 위해) + const formData = new FormData(); + formData.append('rfqId', params.rfqId.toString()); + formData.append('vendorId', params.vendorId.toString()); + formData.append('content', params.content); + formData.append('isVendorComment', 'false'); + + // 첨부파일 추가 + if (params.attachments && params.attachments.length > 0) { + params.attachments.forEach((file) => { + formData.append(`attachments`, file); + }); + } + + // API 엔드포인트 구성 + const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; + + // API 호출 + const response = await fetch(url, { + method: 'POST', + body: formData, // multipart/form-data 형식 사용 + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API 요청 실패: ${response.status} ${errorText}`); + } + + // 응답 데이터 파싱 + const result = await response.json(); + + if (!result.success || !result.data) { + throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다'); + } + + return result.data.comment; + } catch (error) { + console.error('코멘트 전송 오류:', error); + throw error; + } +} + +export function VendorCommunicationDrawer({ + open, + onOpenChange, + selectedRfq, + selectedVendor, + onSuccess +}: VendorCommunicationDrawerProps) { + // 상태 관리 + const [comments, setComments] = useState([]); + const [newComment, setNewComment] = useState(""); + const [attachments, setAttachments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + const messagesEndRef = useRef(null); + + // 첨부파일 관련 상태 + const [previewDialogOpen, setPreviewDialogOpen] = useState(false); + const [selectedAttachment, setSelectedAttachment] = useState(null); + + // 드로어가 열릴 때 데이터 로드 + useEffect(() => { + if (open && selectedRfq && selectedVendor) { + loadComments(); + } + }, [open, selectedRfq, selectedVendor]); + + // 스크롤 최하단으로 이동 + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [comments]); + + // 코멘트 로드 함수 + const loadComments = async () => { + if (!selectedRfq || !selectedVendor) return; + + try { + setIsLoading(true); + + // Server Action을 사용하여 코멘트 데이터 가져오기 + const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId || 0); + setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅 + + // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경 + await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0); + } catch (error) { + console.error("코멘트 로드 오류:", error); + toast.error("메시지를 불러오는 중 오류가 발생했습니다"); + } finally { + setIsLoading(false); + } + }; + + // 파일 선택 핸들러 + const handleFileSelect = () => { + fileInputRef.current?.click(); + }; + + // 파일 변경 핸들러 + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + const newFiles = Array.from(e.target.files); + setAttachments(prev => [...prev, ...newFiles]); + } + }; + + // 파일 제거 핸들러 + const handleRemoveFile = (index: number) => { + setAttachments(prev => prev.filter((_, i) => i !== index)); + }; + + console.log(newComment) + + // 코멘트 전송 핸들러 + const handleSubmitComment = async () => { + console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId ) + console.log(!newComment.trim() && attachments.length === 0) + + if (!newComment.trim() && attachments.length === 0) return; + if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return; + + console.log("버튼 클릭") + + try { + setIsSubmitting(true); + + // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용) + const newCommentObj = await sendComment({ + rfqId: selectedRfq.id, + vendorId: selectedVendor.vendorId, + content: newComment, + attachments: attachments + }); + + // 상태 업데이트 + setComments(prev => [...prev, newCommentObj]); + setNewComment(""); + setAttachments([]); + + toast.success("메시지가 전송되었습니다"); + + // 데이터 새로고침 + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error("코멘트 전송 오류:", error); + toast.error("메시지 전송 중 오류가 발생했습니다"); + } finally { + setIsSubmitting(false); + } + }; + + // 첨부파일 미리보기 + const handleAttachmentPreview = (attachment: Attachment) => { + setSelectedAttachment(attachment); + setPreviewDialogOpen(true); + }; + + // 첨부파일 다운로드 + const handleAttachmentDownload = (attachment: Attachment) => { + // TODO: 실제 다운로드 구현 + window.open(attachment.filePath, '_blank'); + }; + + // 파일 아이콘 선택 + const getFileIcon = (fileType: string) => { + if (fileType.startsWith("image/")) return ; + if (fileType.includes("pdf")) return ; + if (fileType.includes("spreadsheet") || fileType.includes("excel")) + return ; + if (fileType.includes("document") || fileType.includes("word")) + return ; + return ; + }; + + // 첨부파일 미리보기 다이얼로그 + const renderAttachmentPreviewDialog = () => { + if (!selectedAttachment) return null; + + const isImage = selectedAttachment.fileType.startsWith("image/"); + const isPdf = selectedAttachment.fileType.includes("pdf"); + + return ( + + + + + {getFileIcon(selectedAttachment.fileType)} + {selectedAttachment.fileName} + + + {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)} + + + +
+ {isImage ? ( + {selectedAttachment.fileName} + ) : isPdf ? ( +