summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
commit5036cf2908792cef45f06256e71f10920f647f49 (patch)
tree3116e7419e872d45025d1d48e6ddaffe2ba2dd38
parent7ae037e9c2fc0be1fe68cecb461c5e1e837cb0da (diff)
(김준회) 기술영업 조선 RFQ (SHI/벤더)
-rw-r--r--app/[lng]/evcp/(evcp)/budgetary-tech-sales-ship/page.tsx61
-rw-r--r--app/[lng]/partners/(partners)/techsales/rfq-offshore-hull/page.tsx17
-rw-r--r--app/[lng]/partners/(partners)/techsales/rfq-offshore-top/page.tsx17
-rw-r--r--app/[lng]/partners/(partners)/techsales/rfq-ship/[id]/page.tsx126
-rw-r--r--app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx219
-rw-r--r--app/not-found.tsx285
-rw-r--r--components/ui/back-button.tsx112
-rw-r--r--config/menuConfig.ts19
-rw-r--r--db/schema/index.ts1
-rw-r--r--db/schema/techSales.ts473
-rw-r--r--lib/items-tech/service.ts155
-rw-r--r--lib/techsales-rfq/actions.ts59
-rw-r--r--lib/techsales-rfq/repository.ts380
-rw-r--r--lib/techsales-rfq/service.ts1540
-rw-r--r--lib/techsales-rfq/table/README.md41
-rw-r--r--lib/techsales-rfq/table/create-rfq-dialog.tsx537
-rw-r--r--lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx357
-rw-r--r--lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx150
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx291
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx654
-rw-r--r--lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx449
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx521
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx340
-rw-r--r--lib/techsales-rfq/table/project-detail-dialog.tsx322
-rw-r--r--lib/techsales-rfq/table/rfq-filter-sheet.tsx759
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx409
-rw-r--r--lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx63
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx524
-rw-r--r--lib/techsales-rfq/validations.ts119
-rw-r--r--lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx522
-rw-r--r--lib/techsales-rfq/vendor-response/detail/communication-tab.tsx215
-rw-r--r--lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx269
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx382
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx118
-rw-r--r--lib/techsales-rfq/vendor-response/quotation-editor.tsx559
-rw-r--r--lib/techsales-rfq/vendor-response/quotation-item-editor.tsx664
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx365
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx143
-rw-r--r--lib/utils.ts33
-rw-r--r--lib/vendors/service.ts42
40 files changed, 12312 insertions, 0 deletions
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<SearchParams>
+}
+
+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 (
+ <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */}
+ {/* 고정 헤더 영역 */}
+ <div className="flex-shrink-0">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 기술영업-조선 RFQ
+ </h2>
+ </div>
+ </div>
+ </div>
+
+ {/* 테이블 영역 - 남은 공간 모두 차지 */}
+ <div className="flex-1 min-h-0">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RFQListTable promises={promises} className="h-full" />
+ </React.Suspense>
+ </div>
+ </Shell>
+ )
+} \ 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 (
+ <Shell className="gap-6">
+ <div>
+ <h1 className="text-2xl font-bold">기술영업 - 해양 Hull/Top RFQ</h1>
+ <p className="text-muted-foreground">
+ 벤더가 해양 Hull/Top RFQ 목록을 확인하고 관리합니다.
+ </p>
+ <p className="text-muted-foreground">
+ 기술영업 해양 Hull/Top 은 업무 요구사항이 동일하다면 통합으로 개발될 수 있습니다.
+ </p>
+ </div>
+ </Shell>
+ );
+} \ 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 (
+ <Shell className="gap-6">
+ <div>
+ <h1 className="text-2xl font-bold">기술영업 - 해양 Hull/Top RFQ</h1>
+ <p className="text-muted-foreground">
+ 벤더가 해양 Hull/Top RFQ 목록을 확인하고 관리합니다.
+ </p>
+ <p className="text-muted-foreground">
+ 기술영업 해양 Hull/Top 은 업무 요구사항이 동일하다면 통합으로 개발될 수 있습니다.
+ </p>
+ </div>
+ </Shell>
+ );
+} \ 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<Metadata> {
+ 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 (
+ <Shell variant="sidebar" className="gap-4">
+ {/* 헤더 */}
+ <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <h1 className="text-2xl font-bold">견적서 상세</h1>
+ <Badge variant={getStatusBadgeVariant(quotation.status)}>
+ {getStatusLabel(quotation.status)}
+ </Badge>
+ </div>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>RFQ: {rfq?.rfqCode || "미할당"}</span>
+ <span>•</span>
+ <span>벤더: {vendor?.vendorName}</span>
+ <span>•</span>
+ <span>마감일: {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}</span>
+ </div>
+ </div>
+
+ {/* 우측 상단 버튼 */}
+ <div className="flex items-center justify-end">
+ <BackButton segmentsToRemove={1}>
+ 목록으로 돌아가기
+ </BackButton>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 탭 컨텐츠 */}
+ <div className="flex-1 min-h-0">
+ <TechSalesQuotationTabs
+ quotation={quotation}
+ defaultTab={tab || "project"}
+ />
+ </div>
+ </Shell>
+ )
+} \ 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 (
+ <Shell>
+ <div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
+ <div className="text-center">
+ <h2 className="text-2xl font-bold tracking-tight">로그인이 필요합니다</h2>
+ <p className="text-muted-foreground">
+ 견적서를 확인하려면 로그인해주세요.
+ </p>
+ </div>
+ <Button asChild>
+ <Link href="/api/auth/signin">
+ <LogIn className="mr-2 h-4 w-4" />
+ 로그인
+ </Link>
+ </Button>
+ </div>
+ </Shell>
+ );
+ }
+
+ // 벤더 ID 확인 (사용자의 회사 ID가 벤더 ID)
+ const vendorId = session.user.companyId;
+ if (!vendorId) {
+ return (
+ <Shell>
+ <div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
+ <div className="text-center">
+ <h2 className="text-2xl font-bold tracking-tight">회사 정보가 없습니다</h2>
+ <p className="text-muted-foreground">
+ 견적서를 확인하려면 회사 정보가 필요합니다.
+ </p>
+ </div>
+ </div>
+ </Shell>
+ );
+ }
+
+ // 검색 파라미터 파싱 및 검증
+ 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 (
+ <Shell variant="fullscreen" className="h-full">
+ {/* 고정 헤더 영역 */}
+ <div className="flex-shrink-0">
+ <div className="flex-shrink-0 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
+ <div>
+ <h1 className="text-3xl font-bold tracking-tight">기술영업 견적서</h1>
+ <p className="text-muted-foreground">
+ 할당받은 RFQ에 대한 견적서를 작성하고 관리합니다.
+ </p>
+ </div>
+ </div>
+
+ {/* 상태별 개수 카드 */}
+ <div className="flex-shrink-0">
+ <React.Suspense
+ fallback={
+ <div className="w-full overflow-x-auto">
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit">
+ {Array.from({ length: 5 }).map((_, i) => (
+ <Card key={i} className="min-w-[160px]">
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium truncate">로딩중...</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">-</div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </div>
+ }
+ >
+ <StatusCards statusCountsPromise={statusCountsPromise} />
+ </React.Suspense>
+ </div>
+
+ {/* 견적서 테이블 */}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={12}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="h-full overflow-auto">
+ <VendorQuotationsTable promises={Promise.all([quotationsPromise.then(result => ({ data: result.data, pageCount: result.pageCount }))])} />
+ </div>
+ </React.Suspense>
+ </div>
+ </div>
+ </Shell>
+ );
+}
+
+// 상태별 개수 카드 컴포넌트
+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 (
+ <div className="w-full overflow-x-auto">
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 min-w-fit">
+ <Card className="min-w-[160px]">
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium truncate">오류</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-red-600">-</div>
+ <p className="text-xs text-muted-foreground truncate">
+ 데이터를 불러올 수 없습니다
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ );
+ }
+
+ // 중앙화된 상태 설정 사용
+ const statusEntries = Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
+ key: statusValue,
+ ...TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue]
+ }));
+
+ console.log(statusCounts, "statusCounts")
+
+ return (
+ <div className="w-full overflow-x-auto">
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 min-w-fit">
+ {statusEntries.map((status) => (
+ <Card key={status.key} className="min-w-[160px]">
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium truncate">{status.label}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className={`text-2xl font-bold ${status.color}`}>
+ {statusCounts.find(item => item.status === status.key)?.count || 0}
+ </div>
+ <p className="text-xs text-muted-foreground truncate">
+ {status.description}
+ </p>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </div>
+ );
+} \ 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<ErrorInfo | null>(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 (
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
+ <Card className="w-full max-w-2xl shadow-lg">
+ <CardHeader className="text-center space-y-4">
+ <div className="mx-auto w-24 h-24 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center">
+ <span className="text-4xl font-bold text-slate-600 dark:text-slate-400">404</span>
+ </div>
+ <CardTitle className="text-3xl font-bold text-slate-900 dark:text-slate-100">
+ 이 페이지는 개발중이거나, 잘못된 URL 입니다.
+ </CardTitle>
+ <CardDescription className="text-lg text-slate-600 dark:text-slate-400">
+ THIS PAGE IS UNDER DEVELOPMENT OR INVALID URL.
+ </CardDescription>
+ </CardHeader>
+
+ <CardContent className="space-y-6">
+ <Separator />
+
+ <div className="text-center text-sm text-slate-500 dark:text-slate-400">
+ 아래 버튼을 통해 원하는 페이지로 이동하세요
+ </div>
+
+ <div className="grid gap-4 sm:grid-cols-3">
+ {/* SHI 사용자 홈 */}
+ <Button
+ asChild
+ variant="outline"
+ className="h-auto p-4 flex flex-col items-center space-y-2 hover:bg-blue-50 hover:border-blue-200 dark:hover:bg-blue-950"
+ >
+ <Link href="/ko/evcp/dashboard">
+ <Building2 className="h-6 w-6 text-blue-600 dark:text-blue-400" />
+ <span className="font-medium">SHI 사용자 홈</span>
+ <span className="text-xs text-slate-500 dark:text-slate-400">EVCP 대시보드</span>
+ </Link>
+ </Button>
+
+ {/* 벤더 홈 */}
+ <Button
+ asChild
+ variant="outline"
+ className="h-auto p-4 flex flex-col items-center space-y-2 hover:bg-green-50 hover:border-green-200 dark:hover:bg-green-950"
+ >
+ <Link href="/ko/partners">
+ <Users className="h-6 w-6 text-green-600 dark:text-green-400" />
+ <span className="font-medium">벤더 홈</span>
+ <span className="text-xs text-slate-500 dark:text-slate-400">협력업체 포털</span>
+ </Link>
+ </Button>
+
+ {/* 뒤로 가기 */}
+ <Button
+ onClick={() => router.back()}
+ variant="outline"
+ className="h-auto p-4 flex flex-col items-center space-y-2 hover:bg-slate-50 hover:border-slate-200 dark:hover:bg-slate-800"
+ >
+ <ArrowLeft className="h-6 w-6 text-slate-600 dark:text-slate-400" />
+ <span className="font-medium">뒤로 가기</span>
+ <span className="text-xs text-slate-500 dark:text-slate-400">이전 페이지로</span>
+ </Button>
+ </div>
+
+ <Separator />
+
+ <div className="flex justify-center gap-4">
+
+
+ {/* 에러 정보 다이얼로그 */}
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button variant="ghost" size="sm">
+ <Info className="h-4 w-4 mr-2" />
+ 에러 정보
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <AlertCircle className="h-5 w-5" />
+ 에러 상세 정보
+ </DialogTitle>
+ <DialogDescription>
+ 기술 지원을 위한 상세 환경 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {errorInfo && (
+ <div className="space-y-4">
+ <div className="flex justify-end">
+ <Button onClick={copyErrorInfo} size="sm" variant="outline">
+ <Copy className="h-4 w-4 mr-2" />
+ 전체 복사
+ </Button>
+ </div>
+
+ <div className="space-y-3">
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">발생 시간</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.timestamp}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">요청 URL</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono break-all">
+ {errorInfo.url}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">이전 페이지</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono break-all">
+ {errorInfo.referrer}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">브라우저 정보</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono break-all">
+ {errorInfo.userAgent}
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">플랫폼</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.platform}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">언어</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.language}
+ </div>
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">화면 해상도</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.screen.width} × {errorInfo.screen.height}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">뷰포트 크기</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.viewport.width} × {errorInfo.viewport.height}
+ </div>
+ </div>
+ </div>
+
+ <div className="grid grid-cols-3 gap-4">
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">온라인 상태</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.onLine ? '온라인' : '오프라인'}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">쿠키 활성화</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.cookieEnabled ? '예' : '아니오'}
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <label className="text-sm font-medium">색상 깊이</label>
+ <div className="p-2 bg-muted rounded text-sm font-mono">
+ {errorInfo.screen.colorDepth}bit
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )
+} \ 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개 세그먼트 제거)
+ * <BackButton>목록으로</BackButton>
+ *
+ * // 2개 세그먼트 제거 (/a/b/c/d -> /a/b)
+ * <BackButton segmentsToRemove={2}>상위 목록으로</BackButton>
+ *
+ * // 커스텀 경로로 이동
+ * <BackButton customPath="/dashboard">대시보드로</BackButton>
+ *
+ * // 아이콘 없이 사용
+ * <BackButton showIcon={false}>돌아가기</BackButton>
+ *
+ * // 커스텀 스타일링
+ * <BackButton className="text-blue-600" variant="outline">
+ * 이전 페이지
+ * </BackButton>
+ */
+
+interface BackButtonProps extends React.ComponentPropsWithoutRef<typeof Button> {
+ /**
+ * 제거할 세그먼트 개수 (기본값: 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<typeof Button>,
+ 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<HTMLButtonElement>) => {
+ // 커스텀 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 (
+ <Button
+ ref={ref}
+ variant="ghost"
+ className={cn(
+ "flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto",
+ className
+ )}
+ onClick={handleClick}
+ {...props}
+ >
+ {showIcon && <ArrowLeft className="mr-1 h-4 w-4" />}
+ <span>{children}</span>
+ </Button>
+ )
+})
+
+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,6 +304,7 @@ export const mainNavVendor: MenuSection[] = [
{
title: "구매 관리",
+ useGrouping: true,
items: [
{
title: "기본 계약 서명",
@@ -311,6 +312,24 @@ export const mainNavVendor: MenuSection[] = [
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`,
description: "견적 요청에 대한 응답 작성",
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<Date>().notNull(),
+
+ rfqSendDate: date("rfq_send_date", { mode: "date" }).$type<Date | null>(),
+ status: varchar("status", { length: 30 })
+ .$type<TechSalesRfqStatus>()
+ .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<Array<{
+ pspid: string; // 견적프로젝트번호
+ sersNo: string; // 시리즈번호
+ scDt?: string; // Steel Cutting Date
+ klDt?: string; // Keel Laying Date
+ lcDt?: string; // Launching Date
+ dlDt?: string; // Delivery Date
+ dockNo?: string; // 도크코드
+ dockNm?: string; // 도크명
+ projNo?: string; // SN공사번호(계약후)
+ post1?: string; // SN공사명(계약후)
+ }>>(),
+});
+
+// 기술영업 첨부파일 테이블 (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<Date>(),
+
+ // === [끝] 견적 응답 정보 ===
+ // 상태 관리
+ status: varchar("status", { length: 30 })
+ .$type<TechSalesQuotationStatus>()
+ .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<any, any, any>,
+ data: NewTechSalesRfq
+) {
+ return tx
+ .insert(techSalesRfqs)
+ .values(data)
+ .returning({ id: techSalesRfqs.id, createdAt: techSalesRfqs.createdAt });
+}
+
+/**
+ * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
+ * - 트랜잭션(tx)을 받아서 사용하도록 구현
+ */
+export async function selectTechSalesRfqs(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ 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<any, any, any>,
+ 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<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[];
+ 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<string>`created_user.name`,
+ updatedBy: techSalesRfqs.updatedBy,
+ updatedByName: sql<string>`updated_user.name`,
+ sentBy: techSalesRfqs.sentBy,
+ sentByName: sql<string | null>`sent_user.name`,
+
+ // 프로젝트 정보 (스냅샷)
+ projectSnapshot: techSalesRfqs.projectSnapshot,
+ seriesSnapshot: techSalesRfqs.seriesSnapshot,
+
+ // 프로젝트 핵심 정보
+ pspid: sql<string>`${techSalesRfqs.projectSnapshot}->>'pspid'`,
+ projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`,
+ sector: sql<string>`${techSalesRfqs.projectSnapshot}->>'sector'`,
+ projMsrm: sql<number>`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`,
+ ptypeNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`,
+
+ // 첨부파일 개수
+ attachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ // 벤더 견적 개수
+ quotationCount: sql<number>`(
+ 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<any, any, any>,
+ 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<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[];
+ 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<string | null>`created_user.name`,
+ updatedBy: techSalesVendorQuotations.updatedBy,
+ updatedByName: sql<string | null>`updated_user.name`,
+
+ // 프로젝트 정보
+ materialCode: techSalesRfqs.materialCode,
+ itemId: techSalesRfqs.itemId,
+ itemName: items.itemName,
+
+ // 프로젝트 핵심 정보
+ pspid: sql<string>`${techSalesRfqs.projectSnapshot}->>'pspid'`,
+ projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`,
+ sector: sql<string>`${techSalesRfqs.projectSnapshot}->>'sector'`,
+
+ // 첨부파일 개수
+ attachmentCount: sql<number>`(
+ 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<any, any, any>,
+ 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<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[];
+ 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<string>`${techSalesRfqs.projectSnapshot}->>'pspid'`,
+ projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`,
+ sector: sql<string>`${techSalesRfqs.projectSnapshot}->>'sector'`,
+ projMsrm: sql<number>`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`,
+ ptypeNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`,
+
+ // 벤더 견적 통계
+ vendorCount: sql<number>`(
+ SELECT COUNT(DISTINCT vendor_id)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ quotationCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ submittedQuotationCount: sql<number>`(
+ 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<string | null>`(
+ 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<string | null>`(
+ 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<string | null>`(
+ 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<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ // 코멘트 통계
+ commentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_comments
+ WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ unreadCommentCount: sql<number>`(
+ 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<string>`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<string[]> {
+ 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<typeof techSalesVendorQuotations>[];
+ 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<typeof techSalesVendorQuotations>[],
+ 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<typeof techSalesRfqs>[];
+ 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<typeof techSalesRfqs>[],
+ 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<typeof techSalesVendorQuotations>[];
+ joinOperator?: "and" | "or";
+ basicFilters?: Filter<typeof techSalesVendorQuotations>[];
+ 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<typeof techSalesVendorQuotations>[],
+ 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<string>`${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<number>`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<number>`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<typeof createRfqSchema>
+
+// 공종 타입 정의
+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<Project | null>(null)
+
+ // 검색 및 필터링 상태
+ const [itemSearchQuery, setItemSearchQuery] = React.useState("")
+ const [selectedWorkType, setSelectedWorkType] = React.useState<WorkType | null>(null)
+ const [selectedItems, setSelectedItems] = React.useState<ShipbuildingItem[]>([])
+ const [isSearchingItems, setIsSearchingItems] = React.useState(false)
+
+ // 데이터 상태
+ const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
+ const [availableItems, setAvailableItems] = React.useState<ShipbuildingItem[]>([])
+ const [isLoadingItems, setIsLoadingItems] = React.useState(false)
+
+ // RFQ 생성 폼
+ const form = useForm<CreateRfqFormValues>({
+ 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 (
+ <Dialog
+ open={isDialogOpen}
+ onOpenChange={(open) => {
+ 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([])
+ }
+ }}
+ >
+ <DialogTrigger asChild>
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2"
+ disabled={isProcessing}
+ >
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">RFQ 생성</span>
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl w-[90vw] h-[90vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>RFQ 생성</DialogTitle>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-4">
+ {/* 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="biddingProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰 프로젝트</FormLabel>
+ <FormControl>
+ <EstimateProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="입찰 프로젝트를 선택하세요"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {/* 마감일 설정 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>마감일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP", { locale: ko })
+ ) : (
+ <span>마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormDescription>
+ 벤더가 견적을 제출해야 하는 마감일입니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {!selectedProject ? (
+ <div className="text-sm text-muted-foreground italic text-center py-8">
+ 먼저 프로젝트를 선택해주세요
+ </div>
+ ) : (
+ <div className="space-y-6">
+ {/* 아이템 선택 영역 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>조선 아이템 선택</FormLabel>
+ <FormDescription>
+ 공종별 아이템을 선택하세요
+ </FormDescription>
+ </div>
+
+ {/* 아이템 검색 및 필터 */}
+ <div className="space-y-2">
+ <div className="flex space-x-2">
+ <div className="relative flex-1">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="아이템 검색..."
+ value={itemSearchQuery}
+ onChange={(e) => setItemSearchQuery(e.target.value)}
+ className="pl-8 pr-8"
+ />
+ {itemSearchQuery && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3"
+ onClick={() => setItemSearchQuery("")}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ {isSearchingItems && (
+ <Loader2 className="absolute right-8 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />
+ )}
+ </div>
+
+ {/* 공종 필터 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" className="gap-1">
+ {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"}
+ <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuCheckboxItem
+ checked={selectedWorkType === null}
+ onCheckedChange={() => setSelectedWorkType(null)}
+ >
+ 전체 공종
+ </DropdownMenuCheckboxItem>
+ {workTypes.map(workType => (
+ <DropdownMenuCheckboxItem
+ key={workType.code}
+ checked={selectedWorkType === workType.code}
+ onCheckedChange={() => setSelectedWorkType(workType.code)}
+ >
+ {workType.name}
+ </DropdownMenuCheckboxItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+
+ {/* 아이템 목록 */}
+ <div className="border rounded-md">
+ <ScrollArea className="h-[300px]">
+ <div className="p-2 space-y-1">
+ {isLoadingItems ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
+ 아이템을 불러오는 중...
+ </div>
+ ) : availableItems.length > 0 ? (
+ availableItems.map((item) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+ return (
+ <div
+ key={item.id}
+ className={cn(
+ "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
+ isSelected && "bg-muted"
+ )}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ {isSelected ? (
+ <CheckSquare className="h-4 w-4" />
+ ) : (
+ <Square className="h-4 w-4" />
+ )}
+ <div className="flex-1">
+ <div className="font-medium">
+ {item.itemList || item.itemName}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemCode} • {item.description || '설명 없음'}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType} • 선종: {item.shipTypes}
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+ })
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+
+ {/* 선택된 아이템 목록 */}
+ <FormField
+ control={form.control}
+ name="materialCodes"
+ render={() => (
+ <FormItem>
+ <FormLabel>선택된 아이템 ({selectedItems.length}개)</FormLabel>
+ <div className="min-h-[80px] p-3 border rounded-md bg-muted/50">
+ {selectedItems.length > 0 ? (
+ <div className="flex flex-wrap gap-2">
+ {selectedItems.map((item) => (
+ <Badge
+ key={item.id}
+ variant="secondary"
+ className="flex items-center gap-1"
+ >
+ {item.itemList || item.itemName} ({item.itemCode})
+ <X
+ className="h-3 w-3 cursor-pointer hover:text-destructive"
+ onClick={() => handleRemoveItem(item.id)}
+ />
+ </Badge>
+ ))}
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
+ 선택된 아이템이 없습니다
+ </div>
+ )}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 안내 메시지 */}
+ {selectedProject && (
+ <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
+ <p>• 공종별 조선 아이템을 선택하세요.</p>
+ <p>• 선택된 아이템의 자재코드(item_code)별로 개별 RFQ가 생성됩니다.</p>
+ <p>• 아이템 코드가 자재 그룹 코드로 사용됩니다.</p>
+ <p>• 마감일은 벤더가 견적을 제출해야 하는 날짜입니다.</p>
+ </div>
+ )}
+
+ <div className="flex justify-end space-x-2 pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setIsDialogOpen(false)}
+ disabled={isProcessing}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isProcessing || !selectedProject || selectedItems.length === 0}
+ >
+ {isProcessing ? "처리 중..." : `${selectedItems.length}개 자재코드로 생성하기`}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ 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<typeof vendorFormSchema>
+
+// 기술영업 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<VendorSearchResult[]>([])
+ const [isSearching, setIsSearching] = useState(false)
+ const [hasSearched, setHasSearched] = useState(false)
+ // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지
+ const [selectedVendorData, setSelectedVendorData] = useState<VendorSearchResult[]>([])
+
+ const form = useForm<VendorFormValues>({
+ 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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col">
+ {/* 헤더 */}
+ <DialogHeader>
+ <DialogTitle>벤더 추가</DialogTitle>
+ <DialogDescription>
+ {selectedRfq ? (
+ <>
+ <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
+ </>
+ ) : (
+ "RFQ에 벤더를 추가합니다."
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 콘텐츠 */}
+ <div className="flex-1 overflow-y-auto">
+ <Form {...form}>
+ <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 벤더 검색 필드 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">벤더 검색</label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="벤더명 또는 벤더코드로 검색..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="pl-10"
+ />
+ {isSearching && (
+ <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
+ )}
+ </div>
+ </div>
+
+ {/* 검색 결과 */}
+ {hasSearched && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">
+ 검색 결과 ({searchResults.length}개)
+ </div>
+ <ScrollArea className="h-60 border rounded-md">
+ <div className="p-2 space-y-1">
+ {searchResults.length > 0 ? (
+ searchResults.map((vendor) => (
+ <div
+ key={vendor.id}
+ className={`flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted ${
+ selectedVendorIds.includes(vendor.id) ? "bg-muted" : ""
+ }`}
+ onClick={() => handleVendorToggle(vendor)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ <Check
+ className={`h-4 w-4 ${
+ selectedVendorIds.includes(vendor.id)
+ ? "opacity-100"
+ : "opacity-0"
+ }`}
+ />
+ <div className="flex-1">
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-sm text-muted-foreground">
+ {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`}
+ </div>
+ </div>
+ </div>
+ </div>
+ ))
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ 검색 결과가 없습니다
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ )}
+
+ {/* 검색 안내 메시지 */}
+ {!hasSearched && !searchTerm && (
+ <div className="text-center py-8 text-muted-foreground border rounded-md">
+ 벤더명 또는 벤더코드를 입력하여 검색해주세요
+ </div>
+ )}
+
+ {/* 선택된 벤더 목록 - 하단에 항상 표시 */}
+ <FormField
+ control={form.control}
+ name="vendorIds"
+ render={() => (
+ <FormItem>
+ <div className="space-y-2">
+ <FormLabel>선택된 벤더 ({selectedVendorData.length}개)</FormLabel>
+ <div className="min-h-[60px] p-3 border rounded-md bg-muted/50">
+ {selectedVendorData.length > 0 ? (
+ <div className="flex flex-wrap gap-2">
+ {selectedVendorData.map((vendor) => (
+ <Badge
+ key={vendor.id}
+ variant="secondary"
+ className="flex items-center gap-1"
+ >
+ {vendor.vendorName} ({vendor.vendorCode || 'N/A'})
+ <X
+ className="h-3 w-3 cursor-pointer hover:text-destructive"
+ onClick={() => handleRemoveVendor(vendor.id)}
+ />
+ </Badge>
+ ))}
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
+ 선택된 벤더가 없습니다
+ </div>
+ )}
+ </div>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 안내 메시지 */}
+ <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
+ {/* <p>• 검색은 ACTIVE 상태의 벤더만 대상으로 합니다.</p> */}
+ <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p>
+ <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p>
+ <p>• 이미 추가된 벤더는 검색 결과에서 체크됩니다.</p>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* 푸터 */}
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="vendor-form"
+ disabled={isSubmitting || selectedVendorIds.length === 0}
+ >
+ {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ 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<typeof Dialog> {
+ 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 (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ 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<TData> {
+ row: Row<TData>;
+ 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<TData> {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TData> | null>
+ >;
+ unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수
+}
+
+export function getRfqDetailColumns({
+ setRowAction,
+ unreadMessages = {}
+}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.status;
+ const isDraft = status === "Draft";
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ disabled={!isDraft}
+ aria-label="행 선택"
+ className={!isDraft ? "opacity-50 cursor-not-allowed" : ""}
+ />
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 상태" />
+ ),
+ 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 (
+ <Badge variant={variant}>{status || "Draft"}</Badge>
+ );
+ },
+ meta: {
+ excelHeader: "견적 상태"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>,
+ meta: {
+ excelHeader: "벤더 코드"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("vendorName")}</div>,
+ meta: {
+ excelHeader: "벤더명"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 금액" />
+ ),
+ 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 (
+ <div className="font-medium">
+ {isNaN(numValue) ? value : numValue.toLocaleString()} {currency}
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "견적 금액"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="통화" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("currency")}</div>,
+ meta: {
+ excelHeader: "통화"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "validUntil",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효기간" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue() as Date | null;
+ return value ? formatDate(value, "KR") : "-";
+ },
+ meta: {
+ excelHeader: "유효기간"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue() as Date | null;
+ return value ? formatDate(value, "KR") : "-";
+ },
+ meta: {
+ excelHeader: "제출일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
+ meta: {
+ excelHeader: "등록자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "remark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("remark") || "-"}</div>,
+ meta: {
+ excelHeader: "비고"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
+ id: "actions",
+ header: () => <div className="text-right">동작</div>,
+ cell: function Cell({ row }) {
+ const vendorId = row.original.vendorId;
+ const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0;
+
+ return (
+ <div className="text-right flex items-center justify-end gap-1">
+ {/* 커뮤니케이션 버튼 */}
+ <div className="relative">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ onClick={() => setRowAction({ row, type: "communicate" })}
+ title="벤더와 커뮤니케이션"
+ >
+ <MessageCircle className="h-4 w-4" />
+ </Button>
+ {unreadCount > 0 && (
+ <Badge
+ variant="destructive"
+ className="absolute -top-1 -right-1 h-4 w-4 p-0 text-xs flex items-center justify-center"
+ >
+ {unreadCount > 9 ? '9+' : unreadCount}
+ </Badge>
+ )}
+ </div>
+
+ {/* 기존 드롭다운 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="h-4 w-4" />
+ <span className="sr-only">메뉴 열기</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[160px]">
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "update" })}
+ >
+ 벤더 수정
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "delete" })}
+ className="text-destructive focus:text-destructive"
+ >
+ 벤더 제거
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ );
+ },
+ 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<RfqDetailView[]>([])
+ const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
+ const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false)
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
+ const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(null)
+
+ const [vendors, setVendors] = React.useState<Vendor[]>([])
+ const [currencies, setCurrencies] = React.useState<Currency[]>([])
+ const [paymentTerms, setPaymentTerms] = React.useState<PaymentTerm[]>([])
+ const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
+ const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null)
+
+ // 벤더 커뮤니케이션 상태 관리
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
+ const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null)
+
+ // 읽지 않은 메시지 개수
+ const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
+
+ // 견적 비교 다이얼로그 상태 관리
+ const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false)
+
+ // 테이블 선택 상태 관리
+ const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([])
+ 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 (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ RFQ를 선택하세요
+ </div>
+ )
+ }
+
+ // 로딩 중인 경우
+ if (isLoading) {
+ return (
+ <div className="p-4 space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-24 w-full" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ )
+ }
+
+ return (
+ <div className="h-full overflow-hidden pt-4">
+ {/* 테이블 또는 빈 상태 표시 */}
+ {details.length > 0 ? (
+ <ClientDataTable
+ columns={columns}
+ data={details}
+ advancedFilterFields={advancedFilterFields}
+ maxHeight={maxHeight}
+ onSelectedRowsChange={handleSelectedRowsChange}
+ >
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2 mr-2">
+ {selectedRows.length > 0 && (
+ <Badge variant="default" className="h-6">
+ {selectedRows.length}개 선택됨
+ </Badge>
+ )}
+ {totalUnreadMessages > 0 && (
+ <Badge variant="destructive" className="h-6">
+ 읽지 않은 메시지: {totalUnreadMessages}건
+ </Badge>
+ )}
+ {vendorsWithQuotations > 0 && (
+ <Badge variant="outline" className="h-6">
+ 견적 제출: {vendorsWithQuotations}개 벤더
+ </Badge>
+ )}
+ </div>
+ <div className="flex gap-2">
+ {/* RFQ 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendRfq}
+ disabled={selectedRows.length === 0 || isSendingRfq}
+ className="gap-2"
+ >
+ {isSendingRfq ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Send className="size-4" aria-hidden="true" />
+ )}
+ <span>RFQ 발송</span>
+ </Button>
+
+ {/* 벤더 삭제 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDeleteVendors}
+ disabled={selectedRows.length === 0 || isDeletingVendors}
+ className="gap-2"
+ >
+ {isDeletingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Trash2 className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 삭제</span>
+ </Button>
+
+ {/* 견적 비교 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleOpenComparisonDialog}
+ className="gap-2"
+ disabled={
+ !selectedRfq ||
+ details.length === 0 ||
+ vendorsWithQuotations === 0
+ }
+ >
+ <BarChart2 className="size-4" aria-hidden="true" />
+ <span>견적 비교/선택</span>
+ </Button>
+
+ {/* 벤더 추가 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ </ClientDataTable>
+ ) : (
+ <div className="flex h-full items-center justify-center text-muted-foreground">
+ <div className="text-center">
+ <p className="text-lg font-medium">벤더가 없습니다</p>
+ <p className="text-sm">벤더를 추가하여 RFQ를 시작하세요</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="mt-4 gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 다이얼로그들 */}
+ <AddVendorDialog
+ open={vendorDialogOpen}
+ onOpenChange={setVendorDialogOpen}
+ selectedRfq={selectedRfq}
+ existingVendorIds={existingVendorIds}
+ onSuccess={handleRefreshData}
+ />
+
+ <UpdateVendorSheet
+ open={updateSheetOpen}
+ onOpenChange={setUpdateSheetOpen}
+ detail={selectedDetail}
+ vendors={vendors}
+ currencies={currencies}
+ paymentTerms={paymentTerms}
+ incoterms={incoterms}
+ onSuccess={handleRefreshData}
+ />
+
+ <DeleteVendorDialog
+ open={deleteDialogOpen}
+ onOpenChange={setDeleteDialogOpen}
+ detail={selectedDetail}
+ showTrigger={false}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 커뮤니케이션 드로어 */}
+ <VendorCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ selectedRfq={selectedRfq}
+ selectedVendor={selectedVendor}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 견적 비교 다이얼로그 */}
+ <VendorQuotationComparisonDialog
+ open={comparisonDialogOpen}
+ onOpenChange={setComparisonDialogOpen}
+ selectedRfq={selectedRfq}
+ />
+ </div>
+ )
+} \ 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<typeof updateRfqDetailSchema>
+
+// 데이터 타입 정의
+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<typeof Sheet> {
+ 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<UpdateRfqDetailFormValues>({
+ 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 (
+ <Sheet {...props}>
+ <SheetContent className="flex w-full flex-col gap-6 sm:max-w-xl">
+ <SheetHeader className="text-left">
+ <SheetTitle>RFQ 벤더 정보 수정</SheetTitle>
+ <SheetDescription>
+ 벤더 정보를 수정하고 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+ <ScrollArea className="flex-1 pr-4">
+ <Form {...form}>
+ <form
+ id="update-rfq-detail-form"
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* 검색 가능한 벤더 선택 필드 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>벤더 <span className="text-red-500">*</span></FormLabel>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value
+ ? vendors.find((vendor) => String(vendor.id) === field.value)
+ ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})`
+ : "벤더를 선택하세요"
+ : "벤더를 선택하세요"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="벤더 검색..." />
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <ScrollArea className="h-60">
+ <CommandGroup>
+ {vendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => {
+ form.setValue("vendorId", String(vendor.id), {
+ shouldValidate: true,
+ })
+ setVendorOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ String(vendor.id) === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {vendor.vendorName} ({vendor.vendorCode})
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </ScrollArea>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.name} ({currency.code})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="paymentTermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>지불 조건 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="지불 조건 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {paymentTerms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incoterms.map((incoterm) => (
+ <SelectItem key={incoterm.code} value={incoterm.code}>
+ {incoterm.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="incotermsDetail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 세부사항</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="인코텀즈 세부사항" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="deliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>납품 예정일</FormLabel>
+ <FormControl>
+ <Input {...field} type="date" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="taxCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>세금 코드</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="세금 코드" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="placeOfShipping"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선적지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="선적지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="placeOfDestination"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>도착지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="도착지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="materialPriceRelatedYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>자재 가격 관련 여부</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ </ScrollArea>
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ form="update-rfq-detail-form"
+ disabled={isUpdatePending}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ 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<Comment> {
+ 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<Comment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(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<HTMLInputElement>) => {
+ 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 <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType.startsWith("image/");
+ const isPdf = selectedAttachment.fileType.includes("pdf");
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType)}
+ {selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType)}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleAttachmentDownload(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!selectedRfq || !selectedVendor) {
+ return null;
+ }
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[85vh]">
+ <DrawerHeader className="border-b">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ {selectedVendor.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{selectedVendor.vendorName}</span>
+ <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="p-0 flex flex-col h-[60vh]">
+ {/* 메시지 목록 */}
+ <ScrollArea className="flex-1 p-4">
+ {isLoading ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
+ >
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ {comment.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${
+ comment.isVendorComment
+ ? 'bg-muted'
+ : 'bg-primary text-primary-foreground'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? comment.vendorName : comment.userName}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${
+ comment.isVendorComment
+ ? 'border-t border-t-border/30'
+ : 'border-t border-t-primary-foreground/20'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType)}
+ <span className="flex-1 truncate">{attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleAttachmentDownload(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ {comment.userName?.[0] || 'U'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </ScrollArea>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t">
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
new file mode 100644
index 00000000..d58dbd00
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
@@ -0,0 +1,340 @@
+"use client"
+
+import * as React from "react"
+import { useEffect, useState } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { toast } from "sonner"
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
+
+// Lucide 아이콘
+import { Plus, Minus, CheckCircle, Loader2 } from "lucide-react"
+
+import { getTechSalesVendorQuotationsWithJoin } from "@/lib/techsales-rfq/service"
+import { acceptTechSalesVendorQuotationAction } from "@/lib/techsales-rfq/actions"
+import { formatCurrency, formatDate } from "@/lib/utils"
+
+// 기술영업 견적 정보 타입
+interface TechSalesVendorQuotation {
+ id: number
+ rfqId: number
+ vendorId: number
+ vendorName?: string | null
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ status: string
+ remark: string | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface VendorQuotationComparisonDialogProps {
+ 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
+}
+
+export function VendorQuotationComparisonDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: VendorQuotationComparisonDialogProps) {
+ const [isLoading, setIsLoading] = useState(false)
+ const [quotations, setQuotations] = useState<TechSalesVendorQuotation[]>([])
+ const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null)
+ const [isAccepting, setIsAccepting] = useState(false)
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false)
+
+ useEffect(() => {
+ async function loadQuotationData() {
+ if (!open || !selectedRfq?.id) return
+
+ try {
+ setIsLoading(true)
+ // 기술영업 견적 목록 조회 (제출된 견적만)
+ const result = await getTechSalesVendorQuotationsWithJoin({
+ rfqId: selectedRfq.id,
+ page: 1,
+ perPage: 100,
+ filters: [
+ {
+ id: "status" as keyof typeof techSalesVendorQuotations,
+ value: "Submitted",
+ type: "select" as const,
+ operator: "eq" as const,
+ rowId: "status"
+ }
+ ]
+ })
+
+ setQuotations(result.data || [])
+ } catch (error) {
+ console.error("견적 데이터 로드 오류:", error)
+ toast.error("견적 데이터를 불러오는 데 실패했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadQuotationData()
+ }, [open, selectedRfq])
+
+ // 견적 상태 -> 뱃지 색
+ const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "Submitted":
+ return "default"
+ case "Accepted":
+ return "default"
+ case "Rejected":
+ return "destructive"
+ case "Revised":
+ return "destructive"
+ default:
+ return "secondary"
+ }
+ }
+
+ // 벤더 선택 핸들러
+ const handleSelectVendor = (vendorId: number) => {
+ setSelectedVendorId(vendorId)
+ setShowConfirmDialog(true)
+ }
+
+ // 벤더 선택 확정
+ const handleConfirmSelection = async () => {
+ if (!selectedVendorId) return
+
+ try {
+ setIsAccepting(true)
+
+ // 선택된 견적의 ID 찾기
+ const selectedQuotation = quotations.find(q => q.vendorId === selectedVendorId)
+ if (!selectedQuotation) {
+ toast.error("선택된 견적을 찾을 수 없습니다")
+ return
+ }
+
+ // 벤더 선택 API 호출
+ const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id)
+
+ if (result.success) {
+ toast.success(result.message || "벤더가 선택되었습니다")
+ setShowConfirmDialog(false)
+ onOpenChange(false)
+
+ // 페이지 새로고침 또는 데이터 재로드
+ window.location.reload()
+ } else {
+ toast.error(result.error || "벤더 선택에 실패했습니다")
+ }
+ } catch (error) {
+ console.error("벤더 선택 오류:", error)
+ toast.error("벤더 선택에 실패했습니다")
+ } finally {
+ setIsAccepting(false)
+ }
+ }
+
+ const selectedVendor = quotations.find(q => q.vendorId === selectedVendorId)
+
+ return (
+ <>
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]">
+ <DialogHeader>
+ <DialogTitle>벤더 견적 비교 및 선택</DialogTitle>
+ <DialogDescription>
+ {selectedRfq
+ ? `RFQ ${selectedRfq.rfqCode} - 제출된 견적을 비교하고 벤더를 선택하세요`
+ : ""}
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ ) : quotations.length === 0 ? (
+ <div className="py-8 text-center text-muted-foreground">
+ 제출된(Submitted) 견적이 없습니다
+ </div>
+ ) : (
+ <div className="border rounded-md max-h-[60vh] overflow-auto">
+ <table className="table-fixed w-full border-collapse">
+ <thead className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead className="sticky left-0 top-0 z-20 bg-background p-2 w-32">
+ 항목
+ </TableHead>
+ {quotations.map((q) => (
+ <TableHead key={q.id} className="p-2 text-center whitespace-nowrap w-48">
+ <div className="flex flex-col items-center gap-2">
+ <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span>
+ <Button
+ size="sm"
+ variant={q.status === "Accepted" ? "default" : "outline"}
+ onClick={() => handleSelectVendor(q.vendorId)}
+ disabled={q.status === "Accepted"}
+ className="gap-1"
+ >
+ {q.status === "Accepted" ? (
+ <>
+ <CheckCircle className="h-4 w-4" />
+ 선택됨
+ </>
+ ) : (
+ "선택"
+ )}
+ </Button>
+ </div>
+ </TableHead>
+ ))}
+ </TableRow>
+ </thead>
+ <tbody>
+ {/* 견적 상태 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 견적 상태
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`status-${q.id}`} className="p-2 text-center">
+ <Badge variant={getStatusBadgeVariant(q.status)}>
+ {q.status}
+ </Badge>
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 총 금액 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 총 금액
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`total-${q.id}`} className="p-2 font-semibold text-center">
+ {q.totalPrice ? formatCurrency(Number(q.totalPrice), q.currency || 'USD') : '-'}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 통화 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 통화
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`currency-${q.id}`} className="p-2 text-center">
+ {q.currency || '-'}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 유효기간 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 유효 기간
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`valid-${q.id}`} className="p-2 text-center">
+ {q.validUntil ? formatDate(q.validUntil, "KR") : '-'}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 제출일 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 제출일
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`submitted-${q.id}`} className="p-2 text-center">
+ {q.submittedAt ? formatDate(q.submittedAt, "KR") : '-'}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 비고 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 비고
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell
+ key={`remark-${q.id}`}
+ className="p-2 whitespace-pre-wrap text-center"
+ >
+ {q.remark || "-"}
+ </TableCell>
+ ))}
+ </TableRow>
+ </tbody>
+ </table>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 벤더 선택 확인 다이얼로그 */}
+ <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>벤더 선택 확인</AlertDialogTitle>
+ <AlertDialogDescription>
+ <strong>{selectedVendor?.vendorName || `벤더 ID: ${selectedVendorId}`}</strong>를 선택하시겠습니까?
+ <br />
+ <br />
+ 선택된 벤더의 견적이 승인되며, 다른 벤더들의 견적은 자동으로 거절됩니다.
+ 이 작업은 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isAccepting}>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleConfirmSelection}
+ disabled={isAccepting}
+ className="gap-2"
+ >
+ {isAccepting && <Loader2 className="h-4 w-4 animate-spin" />}
+ 확인
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
+}
diff --git a/lib/techsales-rfq/table/project-detail-dialog.tsx b/lib/techsales-rfq/table/project-detail-dialog.tsx
new file mode 100644
index 00000000..b8219d7f
--- /dev/null
+++ b/lib/techsales-rfq/table/project-detail-dialog.tsx
@@ -0,0 +1,322 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { Button } from "@/components/ui/button"
+import { formatDateToQuarter } from "@/lib/utils"
+
+// 프로젝트 스냅샷 타입 정의
+interface ProjectSnapshot {
+ scDt?: string
+ klDt?: string
+ lcDt?: string
+ dlDt?: string
+ dockNo?: string
+ dockNm?: string
+ projNo?: string
+ projNm?: string
+ ownerNm?: string
+ kunnrNm?: string
+ cls1Nm?: string
+ projMsrm?: number
+ ptypeNm?: string
+ sector?: string
+ estmPm?: string
+}
+
+// 시리즈 스냅샷 타입 정의
+interface SeriesSnapshot {
+ sersNo?: string
+ scDt?: string
+ klDt?: string
+ lcDt?: string
+ dlDt?: string
+ dockNo?: string
+ dockNm?: string
+}
+
+// 기본적인 RFQ 타입 정의 (rfq-table.tsx와 일치)
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ itemId: number
+ itemName: string | null
+ materialCode: string | null
+ dueDate: Date
+ rfqSendDate: Date | null
+ status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
+ picCode: string | null
+ remark: string | null
+ cancelReason: string | null
+ createdAt: Date
+ updatedAt: Date
+ createdBy: number | null
+ createdByName: string
+ updatedBy: number | null
+ updatedByName: string
+ sentBy: number | null
+ sentByName: string | null
+ projectSnapshot: ProjectSnapshot | null
+ seriesSnapshot: SeriesSnapshot[] | null
+ pspid: string
+ projNm: string
+ sector: string
+ projMsrm: number
+ ptypeNm: string
+ attachmentCount: number
+ quotationCount: number
+}
+
+interface ProjectDetailDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: TechSalesRfq | null
+}
+
+export function ProjectDetailDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: ProjectDetailDialogProps) {
+ if (!selectedRfq) {
+ return null
+ }
+
+ const projectSnapshot = selectedRfq.projectSnapshot
+ const seriesSnapshot = selectedRfq.seriesSnapshot
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl w-[80vw] max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogHeader className="border-b pb-4">
+ <DialogTitle className="flex items-center gap-2">
+ 프로젝트 상세정보
+ <Badge variant="outline">{selectedRfq.pspid}</Badge>
+ </DialogTitle>
+ <DialogDescription className="space-y-1">
+ <div className="flex items-center gap-2 text-base font-medium">
+ <span>RFQ:</span>
+ <Badge variant="secondary">{selectedRfq.rfqCode || "미할당"}</Badge>
+ <span>|</span>
+ <span>자재:</span>
+ <span className="text-foreground">{selectedRfq.materialCode || "N/A"}</span>
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {selectedRfq.projNm} - {selectedRfq.ptypeNm} ({selectedRfq.itemName || "자재명 없음"})
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-6 p-1 overflow-y-auto">
+ {/* 기본 프로젝트 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기본 정보</h3>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
+ <div className="text-sm">{selectedRfq.pspid}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
+ <div className="text-sm">{selectedRfq.projNm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선종</div>
+ <div className="text-sm">{selectedRfq.ptypeNm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">척수</div>
+ <div className="text-sm">{selectedRfq.projMsrm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">섹터</div>
+ <div className="text-sm">{selectedRfq.sector}</div>
+ </div>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 프로젝트 스냅샷 정보 */}
+ {projectSnapshot && (
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">프로젝트 스냅샷</h3>
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-4">
+ {projectSnapshot.scDt && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">S/C</div>
+ <div className="text-sm">{formatDateToQuarter(projectSnapshot.scDt)}</div>
+ </div>
+ )}
+ {projectSnapshot.klDt && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">K/L</div>
+ <div className="text-sm">{formatDateToQuarter(projectSnapshot.klDt)}</div>
+ </div>
+ )}
+ {projectSnapshot.lcDt && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">L/C</div>
+ <div className="text-sm">{formatDateToQuarter(projectSnapshot.lcDt)}</div>
+ </div>
+ )}
+ {projectSnapshot.dlDt && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">D/L</div>
+ <div className="text-sm">{formatDateToQuarter(projectSnapshot.dlDt)}</div>
+ </div>
+ )}
+ {projectSnapshot.dockNo && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">도크번호</div>
+ <div className="text-sm">{projectSnapshot.dockNo}</div>
+ </div>
+ )}
+ {projectSnapshot.dockNm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">도크명</div>
+ <div className="text-sm">{projectSnapshot.dockNm}</div>
+ </div>
+ )}
+ {projectSnapshot.projNo && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">공사번호</div>
+ <div className="text-sm">{projectSnapshot.projNo}</div>
+ </div>
+ )}
+ {projectSnapshot.projNm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">공사명</div>
+ <div className="text-sm">{projectSnapshot.projNm}</div>
+ </div>
+ )}
+ {projectSnapshot.ownerNm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선주</div>
+ <div className="text-sm">{projectSnapshot.ownerNm}</div>
+ </div>
+ )}
+ {projectSnapshot.kunnrNm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선주명</div>
+ <div className="text-sm">{projectSnapshot.kunnrNm}</div>
+ </div>
+ )}
+ {projectSnapshot.cls1Nm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선급명</div>
+ <div className="text-sm">{projectSnapshot.cls1Nm}</div>
+ </div>
+ )}
+ {projectSnapshot.projMsrm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">척수</div>
+ <div className="text-sm">{projectSnapshot.projMsrm}</div>
+ </div>
+ )}
+ {projectSnapshot.ptypeNm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선종명</div>
+ <div className="text-sm">{projectSnapshot.ptypeNm}</div>
+ </div>
+ )}
+ {projectSnapshot.sector && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">섹터</div>
+ <div className="text-sm">{projectSnapshot.sector}</div>
+ </div>
+ )}
+ {projectSnapshot.estmPm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">견적 PM</div>
+ <div className="text-sm">{projectSnapshot.estmPm}</div>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* 시리즈 스냅샷 정보 */}
+ {seriesSnapshot && Array.isArray(seriesSnapshot) && seriesSnapshot.length > 0 && (
+ <>
+ <Separator />
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">시리즈 정보 스냅샷</h3>
+ <div className="space-y-4">
+ {seriesSnapshot.map((series: SeriesSnapshot, index: number) => (
+ <div key={index} className="border rounded-lg p-4 space-y-3">
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary">시리즈 {series.sersNo || index + 1}</Badge>
+ </div>
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+ {series.scDt && (
+ <div className="space-y-1">
+ <div className="text-xs font-medium text-muted-foreground">S/C</div>
+ <div className="text-sm">{formatDateToQuarter(series.scDt)}</div>
+ </div>
+ )}
+ {series.klDt && (
+ <div className="space-y-1">
+ <div className="text-xs font-medium text-muted-foreground">K/L</div>
+ <div className="text-sm">{formatDateToQuarter(series.klDt)}</div>
+ </div>
+ )}
+ {series.lcDt && (
+ <div className="space-y-1">
+ <div className="text-xs font-medium text-muted-foreground">L/C</div>
+ <div className="text-sm">{formatDateToQuarter(series.lcDt)}</div>
+ </div>
+ )}
+ {series.dlDt && (
+ <div className="space-y-1">
+ <div className="text-xs font-medium text-muted-foreground">D/L</div>
+ <div className="text-sm">{formatDateToQuarter(series.dlDt)}</div>
+ </div>
+ )}
+ {series.dockNo && (
+ <div className="space-y-1">
+ <div className="text-xs font-medium text-muted-foreground">도크번호</div>
+ <div className="text-sm">{series.dockNo}</div>
+ </div>
+ )}
+ {series.dockNm && (
+ <div className="space-y-1">
+ <div className="text-xs font-medium text-muted-foreground">도크명</div>
+ <div className="text-sm">{series.dockNm}</div>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ </>
+ )}
+
+ {/* 추가 정보가 없는 경우 */}
+ {!projectSnapshot && !seriesSnapshot && (
+ <div className="text-center py-8 text-muted-foreground">
+ 추가 프로젝트 상세정보가 없습니다.
+ </div>
+ )}
+ </div>
+
+ {/* 닫기 버튼 */}
+ <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
+ <div className="flex justify-end">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-filter-sheet.tsx b/lib/techsales-rfq/table/rfq-filter-sheet.tsx
new file mode 100644
index 00000000..6021699f
--- /dev/null
+++ b/lib/techsales-rfq/table/rfq-filter-sheet.tsx
@@ -0,0 +1,759 @@
+"use client"
+
+import { useEffect, useTransition, useState, useRef } from "react"
+import { useRouter, useParams } from "next/navigation"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Search, X } from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { DateRangePicker } from "@/components/date-range-picker"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { useTranslation } from '@/i18n/client'
+import { getFiltersStateParser } from "@/lib/parsers"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// 필터 스키마 정의 (TechSales RFQ에 맞게 수정)
+const filterSchema = z.object({
+ rfqCode: z.string().optional(),
+ materialCode: z.string().optional(),
+ itemName: z.string().optional(),
+ pspid: z.string().optional(),
+ projNm: z.string().optional(),
+ ptypeNm: z.string().optional(),
+ createdByName: z.string().optional(),
+ status: z.string().optional(),
+ dateRange: z.object({
+ from: z.date().optional(),
+ to: z.date().optional(),
+ }).optional(),
+})
+
+// 상태 옵션 정의 (TechSales RFQ 상태에 맞게 수정)
+const statusOptions = [
+ { value: "RFQ Created", label: "RFQ Created" },
+ { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" },
+ { value: "RFQ Sent", label: "RFQ Sent" },
+ { value: "Quotation Analysis", label: "Quotation Analysis" },
+ { value: "Closed", label: "Closed" },
+]
+
+type FilterFormValues = z.infer<typeof filterSchema>
+
+interface RFQFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+// Updated component for inline use (not a sheet anymore)
+export function RFQFilterSheet({
+ isOpen,
+ onClose,
+ onSearch,
+ isLoading = false
+}: RFQFilterSheetProps) {
+ const router = useRouter()
+ const params = useParams();
+ const lng = params ? (params.lng as string) : 'ko';
+ const { t } = useTranslation(lng);
+
+ const [isPending, startTransition] = useTransition()
+
+ // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
+ const [isInitializing, setIsInitializing] = useState(false)
+ // 마지막으로 적용된 필터를 추적하기 위한 ref
+ const lastAppliedFilters = useRef<string>("")
+
+ // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경
+ const [filters, setFilters] = useQueryState(
+ "basicFilters",
+ getFiltersStateParser().withDefault([])
+ )
+
+ // joinOperator 설정
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "basicJoinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and")
+ )
+
+ // 현재 URL의 페이지 파라미터도 가져옴
+ const [page, setPage] = useQueryState("page", { defaultValue: "1" })
+
+ // 폼 상태 초기화
+ const form = useForm<FilterFormValues>({
+ resolver: zodResolver(filterSchema),
+ defaultValues: {
+ rfqCode: "",
+ materialCode: "",
+ itemName: "",
+ pspid: "",
+ projNm: "",
+ ptypeNm: "",
+ createdByName: "",
+ status: "",
+ dateRange: {
+ from: undefined,
+ to: undefined,
+ },
+ },
+ })
+
+ // URL 필터에서 초기 폼 상태 설정 - 개선된 버전
+ useEffect(() => {
+ // 현재 필터를 문자열로 직렬화
+ const currentFiltersString = JSON.stringify(filters);
+
+ // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
+ if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
+ setIsInitializing(true);
+
+ const formValues = { ...form.getValues() };
+ let formUpdated = false;
+
+ filters.forEach(filter => {
+ if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) {
+ formValues.dateRange = {
+ from: filter.value[0] ? new Date(filter.value[0]) : undefined,
+ to: filter.value[1] ? new Date(filter.value[1]) : undefined,
+ };
+ formUpdated = true;
+ } else if (filter.id in formValues) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (formValues as any)[filter.id] = filter.value;
+ formUpdated = true;
+ }
+ });
+
+ // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
+ if (formUpdated) {
+ form.reset(formValues);
+ lastAppliedFilters.current = currentFiltersString;
+ }
+
+ setIsInitializing(false);
+ }
+ }, [filters, isOpen, form]) // form 의존성 추가
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+ // 조회 버튼 클릭 핸들러
+ const handleSearch = () => {
+ // 필터 패널 닫기 로직이 있다면 여기에 추가
+ if (onSearch) {
+ onSearch();
+ }
+ }
+
+ // 폼 제출 핸들러 - 개선된 버전
+ async function onSubmit(data: FilterFormValues) {
+ // 초기화 중이면 제출 방지
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ // 필터 배열 생성
+ const newFilters = []
+
+ if (data.rfqCode?.trim()) {
+ newFilters.push({
+ id: "rfqCode",
+ value: data.rfqCode.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.materialCode?.trim()) {
+ newFilters.push({
+ id: "materialCode",
+ value: data.materialCode.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.itemName?.trim()) {
+ newFilters.push({
+ id: "itemName",
+ value: data.itemName.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.pspid?.trim()) {
+ newFilters.push({
+ id: "pspid",
+ value: data.pspid.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.projNm?.trim()) {
+ newFilters.push({
+ id: "projNm",
+ value: data.projNm.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.ptypeNm?.trim()) {
+ newFilters.push({
+ id: "ptypeNm",
+ value: data.ptypeNm.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.createdByName?.trim()) {
+ newFilters.push({
+ id: "createdByName",
+ value: data.createdByName.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select" as const,
+ operator: "eq" as const,
+ rowId: generateId()
+ })
+ }
+
+ // Add date range to params if it exists
+ if (data.dateRange?.from) {
+ newFilters.push({
+ id: "rfqSendDate",
+ value: [
+ data.dateRange.from.toISOString().split('T')[0],
+ data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined
+ ].filter(Boolean) as string[],
+ type: "date" as const,
+ operator: "isBetween" as const,
+ rowId: generateId()
+ })
+ }
+
+ console.log("기본 필터 적용:", newFilters);
+
+ // 마지막 적용된 필터 업데이트
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ // 먼저 필터를 설정
+ await setFilters(newFilters.length > 0 ? newFilters : null);
+
+ // 그 다음 페이지를 1로 설정
+ await setPage("1");
+
+ // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
+ handleSearch();
+
+ // 페이지 새로고침으로 서버 데이터 다시 가져오기
+ setTimeout(() => {
+ window.location.reload();
+ }, 100);
+ } catch (error) {
+ console.error("필터 적용 오류:", error);
+ }
+ })
+ }
+
+ // 필터 초기화 핸들러 - 개선된 버전
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ rfqCode: "",
+ materialCode: "",
+ itemName: "",
+ pspid: "",
+ projNm: "",
+ ptypeNm: "",
+ createdByName: "",
+ status: "",
+ dateRange: { from: undefined, to: undefined },
+ });
+
+ // 필터와 조인 연산자를 초기화
+ await setFilters(null);
+ await setJoinOperator("and");
+ await setPage("1");
+
+ // 마지막 적용된 필터 초기화
+ lastAppliedFilters.current = "";
+
+ console.log("필터 초기화 완료");
+ setIsInitializing(false);
+
+ // 페이지 새로고침으로 서버 데이터 다시 가져오기
+ setTimeout(() => {
+ window.location.reload();
+ }, 100);
+ } catch (error) {
+ console.error("필터 초기화 오류:", error);
+ setIsInitializing(false);
+ }
+ }
+
+ // Don't render if not open (for side panel use)
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full p-4">
+ {/* Filter Panel Header - 보더 제거, 배경 색상 적용 */}
+ <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
+ <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3>
+ </div>
+
+ {/* Join Operator Selection - 보더 제거, 배경 색상 적용 */}
+ <div className="px-6 shrink-0">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(value: "and" | "or") => setJoinOperator(value)}
+ disabled={isInitializing}
+ >
+ <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
+ {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */}
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-6 pt-4">
+ {/* RFQ NO. */}
+ <FormField
+ control={form.control}
+ name="rfqCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ NO.")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("RFQ 번호 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("rfqCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재코드 */}
+ <FormField
+ control={form.control}
+ name="materialCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재코드")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재코드 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("materialCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재명 */}
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("itemName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 ID */}
+ <FormField
+ control={form.control}
+ name="pspid"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("프로젝트 ID")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("프로젝트 ID 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("pspid", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트명 */}
+ <FormField
+ control={form.control}
+ name="projNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("프로젝트명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("프로젝트명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("projNm", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선종명 */}
+ <FormField
+ control={form.control}
+ name="ptypeNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("선종명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("선종명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("ptypeNm", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 요청자 */}
+ <FormField
+ control={form.control}
+ name="createdByName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("요청자")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("요청자 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("createdByName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Status */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("Status")}</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder={t("Select status")} />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {statusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* RFQ 전송일 */}
+ <FormField
+ control={form.control}
+ name="dateRange"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ 전송일")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <DateRangePicker
+ triggerSize="default"
+ triggerClassName="w-full bg-white"
+ align="start"
+ showClearButton={true}
+ placeholder={t("RFQ 전송일 범위를 고르세요")}
+ date={field.value || undefined}
+ onDateChange={field.onChange}
+ disabled={isInitializing}
+ />
+ {(field.value?.from || field.value?.to) && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-10 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("dateRange", { from: undefined, to: undefined });
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* Fixed buttons at bottom - 보더 제거, 배경 색상 적용 */}
+ <div className="p-4 shrink-0">
+ <div className="flex gap-2 justify-end">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
+ className="px-4"
+ >
+ {t("초기화")}
+ </Button>
+ <Button
+ type="submit"
+ variant="samsung"
+ disabled={isPending || isLoading || isInitializing}
+ className="px-4"
+ >
+ <Search className="size-4 mr-2" />
+ {isPending || isLoading ? t("조회 중...") : t("조회")}
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx
new file mode 100644
index 00000000..caaa1c97
--- /dev/null
+++ b/lib/techsales-rfq/table/rfq-table-column.tsx
@@ -0,0 +1,409 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { DataTableRowAction } from "@/types/table"
+import { Check, Pencil, X, Info } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { toast } from "sonner"
+import { Input } from "@/components/ui/input"
+
+// 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함)
+type TechSalesRfq = {
+ id: number
+ rfqCode: string | null
+ itemId: number
+ itemName: string | null
+ materialCode: string | null
+ dueDate: Date
+ rfqSendDate: Date | null
+ status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
+ picCode: string | null
+ remark: string | null
+ cancelReason: string | null
+ createdAt: Date
+ updatedAt: Date
+ createdBy: number | null
+ createdByName: string
+ updatedBy: number | null
+ updatedByName: string
+ sentBy: number | null
+ sentByName: string | null
+ projectSnapshot: any
+ seriesSnapshot: any
+ pspid: string
+ projNm: string
+ sector: string
+ projMsrm: number
+ ptypeNm: string
+ attachmentCount: number
+ quotationCount: number
+ // 나머지 필드는 사용할 때마다 추가
+ [key: string]: any
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
+ // 상태와 상태 설정 함수를 props로 받음
+ editingCell: EditingCellState | null;
+ setEditingCell: (state: EditingCellState | null) => void;
+ updateRemark: (rfqId: number, remark: string) => Promise<void>;
+}
+
+export interface EditingCellState {
+ rowId: string | number;
+ value: string;
+}
+
+
+export function getColumns({
+ setRowAction,
+ editingCell,
+ setEditingCell,
+ updateRemark,
+}: GetColumnsProps): ColumnDef<TechSalesRfq>[] {
+ return [
+ {
+ id: "select",
+ // Remove the "Select all" checkbox in header since we're doing single-select
+ header: () => <span className="sr-only">Select</span>,
+ cell: ({ row, table }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // If selecting this row
+ if (value) {
+ // First deselect all rows (to ensure single selection)
+ table.toggleAllRowsSelected(false)
+ // Then select just this row
+ row.toggleSelected(true)
+ // Trigger the same action that was in the "Select" button
+ setRowAction({ row, type: "select" })
+ } else {
+ // Just deselect this row
+ row.toggleSelected(false)
+ }
+ }}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ },
+
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="진행상태" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("status")}</div>,
+ meta: {
+ excelHeader: "진행상태"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 100,
+ },
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ No." />
+ ),
+ cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>,
+ meta: {
+ excelHeader: "RFQ No."
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "materialCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("materialCode")}</div>,
+ meta: {
+ excelHeader: "자재코드"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 120,
+ },
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재명" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("itemName")}</div>,
+ meta: {
+ excelHeader: "자재명"
+ },
+ enableResizing: true,
+ size: 180,
+ },
+ {
+ accessorKey: "pspid",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트 번호" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("pspid")}</div>,
+ meta: {
+ excelHeader: "프로젝트 번호"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "projNm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("projNm")}</div>,
+ meta: {
+ excelHeader: "프로젝트명"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "projMsrm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="척수" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("projMsrm")}</div>,
+ meta: {
+ excelHeader: "척수"
+ },
+ enableResizing: true,
+ minSize: 60,
+ size: 80,
+ },
+ {
+ accessorKey: "ptypeNm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선종" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("ptypeNm")}</div>,
+ meta: {
+ excelHeader: "선종"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "quotationCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적수" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("quotationCount")}</div>,
+ meta: {
+ excelHeader: "견적수"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "rfqSendDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 전송일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "RFQ 전송일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "RFQ 마감일"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 120,
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="요청자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
+ meta: {
+ excelHeader: "요청자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDateTime(value as Date) : "";
+ },
+ meta: {
+ excelHeader: "등록일"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDateTime(value as Date) : "";
+ },
+ meta: {
+ excelHeader: "수정일"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ // {
+ // accessorKey: "remark",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="비고" />
+ // ),
+ // cell: ({ row }) => {
+ // const id = row.original.id;
+ // const value = row.getValue("remark") as string;
+
+ // const isEditing =
+ // editingCell?.rowId === row.id &&
+ // editingCell.value !== undefined;
+
+ // const startEditing = () => {
+ // setEditingCell({
+ // rowId: row.id,
+ // value: value || ""
+ // });
+ // };
+
+ // const cancelEditing = () => {
+ // setEditingCell(null);
+ // };
+
+ // const saveChanges = async () => {
+ // if (!editingCell) return;
+
+ // try {
+ // await updateRemark(id, editingCell.value);
+ // setEditingCell(null);
+ // } catch (error) {
+ // toast.error("비고 업데이트 중 오류가 발생했습니다.");
+ // console.error("Error updating remark:", error);
+ // }
+ // };
+
+ // const handleKeyDown = (e: React.KeyboardEvent) => {
+ // if (e.key === "Enter") {
+ // saveChanges();
+ // } else if (e.key === "Escape") {
+ // cancelEditing();
+ // }
+ // };
+
+ // if (isEditing) {
+ // return (
+ // <div className="flex items-center gap-1">
+ // <Input
+ // value={editingCell?.value || ""}
+ // onChange={(e) => setEditingCell({
+ // rowId: row.id,
+ // value: e.target.value
+ // })}
+ // onKeyDown={handleKeyDown}
+ // autoFocus
+ // className="h-8 w-full"
+ // />
+ // <Button
+ // variant="ghost"
+ // size="icon"
+ // onClick={saveChanges}
+ // className="h-8 w-8"
+ // >
+ // <Check className="h-4 w-4" />
+ // </Button>
+ // <Button
+ // variant="ghost"
+ // size="icon"
+ // onClick={cancelEditing}
+ // className="h-8 w-8"
+ // >
+ // <X className="h-4 w-4" />
+ // </Button>
+ // </div>
+ // );
+ // }
+
+ // return (
+ // <div className="flex items-center gap-1 group">
+ // <span className="truncate">{value || ""}</span>
+ // <Button
+ // variant="ghost"
+ // size="icon"
+ // onClick={startEditing}
+ // className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
+ // >
+ // <Pencil className="h-3 w-3" />
+ // </Button>
+ // </div>
+ // );
+ // },
+ // meta: {
+ // excelHeader: "비고"
+ // },
+ // enableResizing: true,
+ // size: 200,
+ // },
+ {
+ id: "actions",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="액션" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setRowAction({ row, type: "project-detail" })}
+ className="h-8 px-2 gap-1"
+ >
+ <Info className="h-4 w-4" />
+ <span className="hidden sm:inline">프로젝트 상세</span>
+ </Button>
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ size: 120,
+ minSize: 120,
+ maxSize: 120,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx
new file mode 100644
index 00000000..da716eeb
--- /dev/null
+++ b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx
@@ -0,0 +1,63 @@
+"use client"
+
+import * as React from "react"
+import { Download, RefreshCw } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { type Table } from "@tanstack/react-table"
+import { CreateRfqDialog } from "./create-rfq-dialog"
+
+interface RFQTableToolbarActionsProps<TData> {
+ selection: Table<TData>;
+ onRefresh?: () => void;
+}
+
+export function RFQTableToolbarActions<TData>({
+ selection,
+ onRefresh
+}: RFQTableToolbarActionsProps<TData>) {
+
+ // 데이터 새로고침
+ const handleRefresh = () => {
+ if (onRefresh) {
+ onRefresh();
+ toast.success("데이터를 새로고침했습니다");
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* RFQ 생성 다이얼로그 */}
+ <CreateRfqDialog onCreated={onRefresh} />
+
+ {/* 새로고침 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ className="gap-2"
+ >
+ <RefreshCw className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">새로고침</span>
+ </Button>
+
+ {/* 내보내기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(selection, {
+ filename: "tech_sales_rfq",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">내보내기</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx
new file mode 100644
index 00000000..3139b1a3
--- /dev/null
+++ b/lib/techsales-rfq/table/rfq-table.tsx
@@ -0,0 +1,524 @@
+"use client"
+
+import * as React from "react"
+import { useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import {
+ ResizablePanelGroup,
+ ResizablePanel,
+ ResizableHandle,
+} from "@/components/ui/resizable"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { getColumns, EditingCellState } from "./rfq-table-column"
+import { useEffect, useCallback, useMemo } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
+import { getTechSalesRfqsWithJoin } from "@/lib/techsales-rfq/service"
+import { toast } from "sonner"
+import { useTablePresets } from "@/components/data-table/use-table-presets"
+import { TablePresetManager } from "@/components/data-table/data-table-preset"
+import { RfqDetailTables } from "./detail-table/rfq-detail-table"
+import { cn } from "@/lib/utils"
+import { ProjectDetailDialog } from "./project-detail-dialog"
+import { RFQFilterSheet } from "./rfq-filter-sheet"
+
+// 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤)
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ itemId: number
+ itemName: string | null
+ materialCode: string | null
+ dueDate: Date
+ rfqSendDate: Date | null
+ status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
+ picCode: string | null
+ remark: string | null
+ cancelReason: string | null
+ createdAt: Date
+ updatedAt: Date
+ createdBy: number | null
+ createdByName: string
+ updatedBy: number | null
+ updatedByName: string
+ sentBy: number | null
+ sentByName: string | null
+ projectSnapshot: any
+ seriesSnapshot: any
+ pspid: string
+ projNm: string
+ sector: string
+ projMsrm: number
+ ptypeNm: string
+ attachmentCount: number
+ quotationCount: number
+ // 필요에 따라 다른 필드들 추가
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [key: string]: any
+}
+
+interface RFQListTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]>
+ className?: string;
+ calculatedHeight?: string; // 계산된 높이 추가
+}
+
+export function RFQListTable({
+ promises,
+ className,
+ calculatedHeight
+}: RFQListTableProps) {
+ const searchParams = useSearchParams()
+
+ // 필터 패널 상태
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+
+ // 선택된 RFQ 상태
+ const [selectedRfq, setSelectedRfq] = React.useState<TechSalesRfq | null>(null)
+
+ // 프로젝트 상세정보 다이얼로그 상태
+ const [isProjectDetailOpen, setIsProjectDetailOpen] = React.useState(false)
+ const [projectDetailRfq, setProjectDetailRfq] = React.useState<TechSalesRfq | null>(null)
+
+ // 패널 collapse 상태
+ const [panelHeight, setPanelHeight] = React.useState<number>(55)
+
+ // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요)
+ const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이
+ const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값)
+ const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border)
+ const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비
+
+ // 높이 계산
+ // 필터 패널 높이 - Layout Header와 Footer 사이
+ const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)`
+
+ console.log(calculatedHeight)
+
+ // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외
+ const FIXED_TABLE_HEIGHT = calculatedHeight
+ ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)`
+ : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback
+
+ // Suspense 방식으로 데이터 처리
+ const [promiseData] = React.use(promises)
+ const tableData = promiseData
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechSalesRfq> | null>(null)
+ const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null)
+
+ // 초기 설정 정의
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams?.get('page') || '1'),
+ perPage: parseInt(searchParams?.get('perPage') || '10'),
+ sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
+ filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
+ basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams?.get('search') || '',
+ from: searchParams?.get('from') || undefined,
+ to: searchParams?.get('to') || undefined,
+ columnVisibility: {},
+ columnOrder: [],
+ pinnedColumns: { left: [], right: [] },
+ groupBy: [],
+ expandedRows: []
+ }), [searchParams])
+
+ // DB 기반 프리셋 훅 사용
+ const {
+ presets,
+ activePresetId,
+ hasUnsavedChanges,
+ isLoading: presetsLoading,
+ createPreset,
+ applyPreset,
+ updatePreset,
+ deletePreset,
+ setDefaultPreset,
+ renamePreset,
+ getCurrentSettings,
+ } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings)
+
+ // 비고 업데이트 함수
+ const updateRemark = useCallback(async (rfqId: number, remark: string) => {
+ try {
+ // 기술영업 RFQ 비고 업데이트 함수 구현 필요
+ // const result = await updateTechSalesRfqRemark(rfqId, remark);
+ console.log("Update remark for RFQ:", rfqId, "with:", remark);
+
+ toast.success("비고가 업데이트되었습니다");
+ } catch (error) {
+ console.error("비고 업데이트 오류:", error);
+ toast.error("업데이트 중 오류가 발생했습니다");
+ }
+ }, [])
+
+ // 조회 버튼 클릭 핸들러
+ const handleSearch = () => {
+ setIsFilterPanelOpen(false)
+ }
+
+ // 행 액션 처리
+ useEffect(() => {
+ if (rowAction) {
+ switch (rowAction.type) {
+ case "select":
+ // 객체 참조 안정화를 위해 필요한 필드만 추출
+ const rfqData = rowAction.row.original;
+ setSelectedRfq({
+ id: rfqData.id,
+ rfqCode: rfqData.rfqCode,
+ itemId: rfqData.itemId,
+ itemName: rfqData.itemName,
+ materialCode: rfqData.materialCode,
+ dueDate: rfqData.dueDate,
+ rfqSendDate: rfqData.rfqSendDate,
+ status: rfqData.status,
+ picCode: rfqData.picCode,
+ remark: rfqData.remark,
+ cancelReason: rfqData.cancelReason,
+ createdAt: rfqData.createdAt,
+ updatedAt: rfqData.updatedAt,
+ createdBy: rfqData.createdBy,
+ createdByName: rfqData.createdByName,
+ updatedBy: rfqData.updatedBy,
+ updatedByName: rfqData.updatedByName,
+ sentBy: rfqData.sentBy,
+ sentByName: rfqData.sentByName,
+ projectSnapshot: rfqData.projectSnapshot,
+ seriesSnapshot: rfqData.seriesSnapshot,
+ pspid: rfqData.pspid,
+ projNm: rfqData.projNm,
+ sector: rfqData.sector,
+ projMsrm: rfqData.projMsrm,
+ ptypeNm: rfqData.ptypeNm,
+ attachmentCount: rfqData.attachmentCount,
+ quotationCount: rfqData.quotationCount,
+ });
+ break;
+ case "project-detail":
+ // 프로젝트 상세정보 다이얼로그 열기
+ const projectRfqData = rowAction.row.original;
+ setProjectDetailRfq({
+ id: projectRfqData.id,
+ rfqCode: projectRfqData.rfqCode,
+ itemId: projectRfqData.itemId,
+ itemName: projectRfqData.itemName,
+ materialCode: projectRfqData.materialCode,
+ dueDate: projectRfqData.dueDate,
+ rfqSendDate: projectRfqData.rfqSendDate,
+ status: projectRfqData.status,
+ picCode: projectRfqData.picCode,
+ remark: projectRfqData.remark,
+ cancelReason: projectRfqData.cancelReason,
+ createdAt: projectRfqData.createdAt,
+ updatedAt: projectRfqData.updatedAt,
+ createdBy: projectRfqData.createdBy,
+ createdByName: projectRfqData.createdByName,
+ updatedBy: projectRfqData.updatedBy,
+ updatedByName: projectRfqData.updatedByName,
+ sentBy: projectRfqData.sentBy,
+ sentByName: projectRfqData.sentByName,
+ projectSnapshot: projectRfqData.projectSnapshot,
+ seriesSnapshot: projectRfqData.seriesSnapshot,
+ pspid: projectRfqData.pspid,
+ projNm: projectRfqData.projNm,
+ sector: projectRfqData.sector,
+ projMsrm: projectRfqData.projMsrm,
+ ptypeNm: projectRfqData.ptypeNm,
+ attachmentCount: projectRfqData.attachmentCount,
+ quotationCount: projectRfqData.quotationCount,
+ });
+ setIsProjectDetailOpen(true);
+ break;
+ case "update":
+ console.log("Update rfq:", rowAction.row.original)
+ break;
+ case "delete":
+ console.log("Delete rfq:", rowAction.row.original)
+ break;
+ }
+ setRowAction(null)
+ }
+ }, [rowAction])
+
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ editingCell,
+ setEditingCell,
+ updateRemark
+ }),
+ [editingCell, setEditingCell, updateRemark]
+ )
+
+ // 고급 필터 필드 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<TechSalesRfq>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ No.",
+ type: "text",
+ },
+ {
+ id: "materialCode",
+ label: "자재코드",
+ type: "text",
+ },
+ {
+ id: "itemName",
+ label: "자재명",
+ type: "text",
+ },
+ {
+ id: "pspid",
+ label: "프로젝트 ID",
+ type: "text",
+ },
+ {
+ id: "projNm",
+ label: "프로젝트명",
+ type: "text",
+ },
+ {
+ id: "ptypeNm",
+ label: "선종명",
+ type: "text",
+ },
+ {
+ id: "rfqSendDate",
+ label: "RFQ 전송일",
+ type: "date",
+ },
+ {
+ id: "dueDate",
+ label: "RFQ 마감일",
+ type: "date",
+ },
+ {
+ id: "createdByName",
+ label: "요청자",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "상태",
+ type: "text",
+ },
+ ]
+
+ // 현재 설정 가져오기
+ const currentSettings = useMemo(() => {
+ return getCurrentSettings()
+ }, [getCurrentSettings])
+
+ // useDataTable 초기 상태 설정
+ const initialState = useMemo(() => {
+ return {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ sorting: initialSettings.sort.filter((sortItem: any) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const columnExists = columns.some((col: any) => col.accessorKey === sortItem.id)
+ return columnExists
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }) as any,
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }
+ }, [currentSettings, initialSettings.sort, columns])
+
+ // useDataTable 훅 설정
+ const { table } = useDataTable({
+ data: tableData?.data || [],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ columns: columns as any,
+ pageCount: tableData?.pageCount || 0,
+ rowCount: tableData?.total || 0,
+ filterFields: [],
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState,
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+ })
+
+ // Get active basic filter count
+ const getActiveBasicFilterCount = () => {
+ try {
+ const basicFilters = searchParams?.get('basicFilters')
+ return basicFilters ? JSON.parse(basicFilters).length : 0
+ } catch {
+ return 0
+ }
+ }
+
+ console.log(panelHeight)
+
+ return (
+ <div
+ className={cn("flex flex-col relative", className)}
+ style={{ height: calculatedHeight }}
+ >
+ {/* Filter Panel - 계산된 높이 적용 */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
+ isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ top: `${LAYOUT_HEADER_HEIGHT*2}px`,
+ height: FIXED_FILTER_HEIGHT
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <RFQFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={false}
+ />
+ </div>
+ </div>
+
+ {/* Main Content */}
+ <div
+ className="flex flex-col transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ height: '100%'
+ }}
+ >
+ {/* Header Bar - 고정 높이 */}
+ <div
+ className="flex items-center justify-between p-4 bg-background border-b"
+ style={{
+ height: `${LOCAL_HEADER_HEIGHT}px`,
+ flexShrink: 0
+ }}
+ >
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
+ {getActiveBasicFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveBasicFilterCount()}
+ </span>
+ )}
+ </Button>
+ </div>
+
+ {/* Right side info */}
+ <div className="text-sm text-muted-foreground">
+ {tableData && (
+ <span>총 {tableData.total || 0}건</span>
+ )}
+ </div>
+ </div>
+
+ {/* Table Content Area - 계산된 높이 사용 */}
+ <div
+ className="relative bg-background"
+ style={{
+ height: FIXED_TABLE_HEIGHT,
+ display: 'grid',
+ gridTemplateRows: '1fr',
+ gridTemplateColumns: '1fr'
+ }}
+ >
+ <ResizablePanelGroup
+ direction="vertical"
+ className="w-full h-full"
+ >
+ <ResizablePanel
+ defaultSize={60}
+ minSize={25}
+ maxSize={75}
+ collapsible={false}
+ onResize={(size) => {
+ setPanelHeight(size)
+ }}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 상단 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <DataTable
+ table={table}
+ maxHeight={`${panelHeight*0.5}vh`}
+ >
+ <DataTableAdvancedToolbar
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ table={table as any}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ <TablePresetManager<TechSalesRfq>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ <RFQTableToolbarActions
+ selection={table}
+ onRefresh={() => {}}
+ />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ minSize={25}
+ defaultSize={40}
+ collapsible={false}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 하단 상세 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden bg-background">
+ <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/>
+ </div>
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </div>
+ </div>
+
+ {/* 프로젝트 상세정보 다이얼로그 */}
+ <ProjectDetailDialog
+ open={isProjectDetailOpen}
+ onOpenChange={setIsProjectDetailOpen}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ selectedRfq={projectDetailRfq as any}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/validations.ts b/lib/techsales-rfq/validations.ts
new file mode 100644
index 00000000..9d960525
--- /dev/null
+++ b/lib/techsales-rfq/validations.ts
@@ -0,0 +1,119 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server";
+import * as z from "zod";
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
+import { techSalesRfqs, techSalesVendorQuotations } from "@/db/schema";
+
+
+// =======================
+// 1) SearchParams (목록 필터링/정렬)
+// =======================
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+});
+
+export type GetTechSalesRfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
+
+
+export const searchParamsVendorQuotationsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesVendorQuotations>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+
+});
+
+export type GetTechSalesVendorQuotationsSchema = Awaited<ReturnType<typeof searchParamsVendorQuotationsCache.parse>>;
+
+export const searchParamsDashboardCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+});
+
+export type GetTechSalesDashboardSchema = Awaited<ReturnType<typeof searchParamsDashboardCache.parse>>;
+
+// RFQ 생성 스키마
+export const createTechSalesRfqSchema = z.object({
+ itemId: z.number(),
+ biddingProjectId: z.number().optional(),
+ materialCode: z.string().optional(),
+ dueDate: z.date(),
+ status: z.string().default("RFQ Created"),
+ rfqSealedYn: z.boolean().default(false),
+ picCode: z.string().optional(),
+ remark: z.string().optional().nullable(),
+});
+
+export type CreateTechSalesRfqSchema = z.infer<typeof createTechSalesRfqSchema>;
+
+// 결합도 우려가 있지만
+// 벤더가 기술영업(조선) RFQ 조회할 때 쓸 밸리데이션
+export const searchParamsVendorRfqCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesVendorQuotations>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+});
+
+export type GetQuotationsSchema = Awaited<ReturnType<typeof searchParamsVendorRfqCache.parse>>; \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx
new file mode 100644
index 00000000..69ba0363
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx
@@ -0,0 +1,522 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X,
+ User,
+ Building
+} from "lucide-react"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime, formatFileSize } from "@/lib/utils"
+import { useSession } from "next-auth/react"
+import { fetchBuyerVendorComments } from "../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 | null; // null 허용으로 변경
+ filePath: string;
+ uploadedAt: Date;
+}
+
+// 프롭스 정의
+interface BuyerCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ quotation: {
+ id: number;
+ rfqId: number;
+ vendorId: number;
+ quotationCode: string;
+ rfq?: {
+ rfqCode: string;
+ };
+ } | null;
+ onSuccess?: () => void;
+}
+
+
+
+// 벤더 코멘트 전송 함수
+export function sendVendorCommentClient(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<Comment> {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'true'); // 벤더가 보내는 메시지이므로 true
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성 (벤더 API 경로)
+ const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ // API 호출
+ return fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ })
+ .then(response => {
+ if (!response.ok) {
+ return response.text().then(text => {
+ throw new Error(`API 요청 실패: ${response.status} ${text}`);
+ });
+ }
+ return response.json();
+ })
+ .then(result => {
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+ return result.data.comment;
+ });
+}
+
+
+export function BuyerCommunicationDrawer({
+ open,
+ onOpenChange,
+ quotation,
+ onSuccess
+}: BuyerCommunicationDrawerProps) {
+ // 세션 정보
+ const { data: session } = useSession();
+
+ // 상태 관리
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && quotation) {
+ loadComments();
+ }
+ }, [open, quotation]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 코멘트 로드 함수
+ const loadComments = async () => {
+ if (!quotation) return;
+
+ try {
+ setIsLoading(true);
+
+ // API를 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId);
+ setComments(commentsData);
+
+ // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // 파일 선택 핸들러
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ // 파일 변경 핸들러
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ 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));
+ };
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!quotation) return;
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendVendorCommentClient({
+ rfqId: quotation.rfqId,
+ vendorId: quotation.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) => {
+ // 실제 다운로드 구현
+ window.open(attachment.filePath, '_blank');
+ };
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string) => {
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType.startsWith("image/");
+ const isPdf = selectedAttachment.fileType.includes("pdf");
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType)}
+ {selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType)}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleAttachmentDownload(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!quotation) {
+ return null;
+ }
+
+ // 구매자 정보 (실제로는 API에서 가져와야 함)
+ const buyerName = "구매 담당자";
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[85vh]">
+ <DrawerHeader className="border-b">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ <User className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{buyerName}</span>
+ <Badge variant="outline" className="ml-2">구매자</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {quotation.rfq?.rfqCode || "N/A"} • 견적서: {quotation.quotationCode}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="p-0 flex flex-col h-[60vh]">
+ {/* 메시지 목록 */}
+ <ScrollArea className="flex-1 p-4">
+ {isLoading ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-end' : 'justify-start'}`}
+ >
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ <User className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${comment.isVendorComment
+ ? 'bg-primary text-primary-foreground'
+ : 'bg-muted'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? (
+ session?.user?.name || "벤더"
+ ) : (
+ comment.userName || buyerName
+ )}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${comment.isVendorComment
+ ? 'border-t border-t-primary-foreground/20'
+ : 'border-t border-t-border/30'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType)}
+ <span className="flex-1 truncate">{attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleAttachmentDownload(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ <Building className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </ScrollArea>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t">
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
new file mode 100644
index 00000000..0332232c
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
@@ -0,0 +1,215 @@
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Send, MessageCircle } from "lucide-react"
+import { formatDateTime } from "@/lib/utils"
+import { toast } from "sonner"
+
+interface CommunicationTabProps {
+ quotation: {
+ id: number
+ rfq: {
+ id: number
+ rfqCode: string | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ vendorName: string
+ } | null
+ }
+}
+
+// 임시 코멘트 데이터 (실제로는 API에서 가져와야 함)
+const MOCK_COMMENTS = [
+ {
+ id: 1,
+ content: "안녕하세요. 해당 자재에 대한 견적 요청 드립니다. 납기일은 언제까지 가능한지 문의드립니다.",
+ createdAt: new Date("2024-01-15T09:00:00"),
+ author: {
+ name: "김구매",
+ email: "buyer@company.com",
+ role: "구매담당자"
+ }
+ },
+ {
+ id: 2,
+ content: "안녕하세요. 견적 요청 확인했습니다. 해당 자재의 경우 약 2주 정도의 제작 기간이 필요합니다. 상세한 견적은 내일까지 제출하겠습니다.",
+ createdAt: new Date("2024-01-15T14:30:00"),
+ author: {
+ name: "이벤더",
+ email: "vendor@supplier.com",
+ role: "벤더"
+ }
+ },
+ {
+ id: 3,
+ content: "감사합니다. 추가로 품질 인증서도 함께 제출 가능한지 확인 부탁드립니다.",
+ createdAt: new Date("2024-01-16T10:15:00"),
+ author: {
+ name: "김구매",
+ email: "buyer@company.com",
+ role: "구매담당자"
+ }
+ }
+]
+
+export function CommunicationTab({ quotation }: CommunicationTabProps) {
+ const [newComment, setNewComment] = useState("")
+ const [isLoading, setIsLoading] = useState(false)
+ const [comments, setComments] = useState(MOCK_COMMENTS)
+
+ const handleSendComment = async () => {
+ if (!newComment.trim()) {
+ toast.error("메시지를 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // TODO: API 호출로 코멘트 전송
+ const newCommentData = {
+ id: comments.length + 1,
+ content: newComment,
+ createdAt: new Date(),
+ author: {
+ name: "현재사용자", // 실제로는 세션에서 가져와야 함
+ email: "current@user.com",
+ role: "벤더"
+ }
+ }
+
+ setComments([...comments, newCommentData])
+ setNewComment("")
+ toast.success("메시지가 전송되었습니다.")
+ } catch {
+ toast.error("메시지 전송 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const getAuthorInitials = (name: string) => {
+ return name
+ .split(" ")
+ .map(word => word[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2)
+ }
+
+ const getRoleBadgeVariant = (role: string) => {
+ return role === "구매담당자" ? "default" : "secondary"
+ }
+
+ return (
+ <div className="h-full flex flex-col">
+ {/* 헤더 */}
+ <Card className="mb-4">
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <MessageCircle className="h-5 w-5" />
+ 커뮤니케이션
+ </CardTitle>
+ <CardDescription>
+ RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>구매담당자: {quotation.rfq?.createdByUser?.name || "N/A"}</span>
+ <span>•</span>
+ <span>벤더: {quotation.vendor?.vendorName}</span>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 메시지 목록 */}
+ <Card className="flex-1 flex flex-col min-h-0">
+ <CardHeader>
+ <CardTitle className="text-lg">메시지 ({comments.length})</CardTitle>
+ </CardHeader>
+ <CardContent className="flex-1 flex flex-col min-h-0">
+ <ScrollArea className="flex-1 pr-4">
+ <div className="space-y-4">
+ {comments.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <MessageCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>아직 메시지가 없습니다.</p>
+ <p className="text-sm">첫 번째 메시지를 보내보세요.</p>
+ </div>
+ ) : (
+ comments.map((comment) => (
+ <div key={comment.id} className="flex gap-3">
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="text-xs">
+ {getAuthorInitials(comment.author.name)}
+ </AvatarFallback>
+ </Avatar>
+ <div className="flex-1 space-y-2">
+ <div className="flex items-center gap-2">
+ <span className="font-medium text-sm">{comment.author.name}</span>
+ <Badge variant={getRoleBadgeVariant(comment.author.role)} className="text-xs">
+ {comment.author.role}
+ </Badge>
+ <span className="text-xs text-muted-foreground">
+ {formatDateTime(comment.createdAt)}
+ </span>
+ </div>
+ <div className="bg-muted p-3 rounded-lg text-sm">
+ {comment.content}
+ </div>
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+ </ScrollArea>
+
+ <Separator className="my-4" />
+
+ {/* 새 메시지 입력 */}
+ <div className="space-y-3">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ rows={3}
+ className="resize-none"
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault()
+ handleSendComment()
+ }
+ }}
+ />
+ <div className="flex justify-between items-center">
+ <div className="text-xs text-muted-foreground">
+ Ctrl + Enter로 빠른 전송
+ </div>
+ <Button
+ onClick={handleSendComment}
+ disabled={isLoading || !newComment.trim()}
+ size="sm"
+ >
+ <Send className="h-4 w-4 mr-2" />
+ 전송
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
new file mode 100644
index 00000000..7ba3320d
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
@@ -0,0 +1,269 @@
+"use client"
+
+import * as React from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { formatDateToQuarter, formatDate } from "@/lib/utils"
+
+interface ProjectSnapshot {
+ pspid?: string
+ projNm?: string
+ projMsrm?: number
+ kunnr?: string
+ kunnrNm?: string
+ cls1?: string
+ cls1Nm?: string
+ ptype?: string
+ ptypeNm?: string
+ estmPm?: string
+ scDt?: string
+ klDt?: string
+ lcDt?: string
+ dlDt?: string
+ dockNo?: string
+ dockNm?: string
+ projNo?: string
+ ownerNm?: string
+ pspUpdatedAt?: string | Date
+}
+
+interface SeriesSnapshot {
+ sersNo?: string
+ klDt?: string
+}
+
+interface ProjectInfoTabProps {
+ quotation: {
+ id: number
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ remark: string | null
+ projectSnapshot?: ProjectSnapshot | null
+ seriesSnapshot?: SeriesSnapshot[] | null
+ item?: {
+ id: number
+ itemCode: string | null
+ itemName: string | null
+ } | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: string | null
+ } | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ } | null
+ }
+}
+
+export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
+ const rfq = quotation.rfq
+ const projectSnapshot = rfq?.projectSnapshot
+ const seriesSnapshot = rfq?.seriesSnapshot
+
+ if (!rfq) {
+ return (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center">
+ <h3 className="text-lg font-medium">RFQ 정보를 찾을 수 없습니다</h3>
+ <p className="text-sm text-muted-foreground mt-1">
+ 연결된 RFQ 정보가 없습니다.
+ </p>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <ScrollArea className="h-full">
+ <div className="space-y-6 p-1">
+ {/* RFQ 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ RFQ 기본 정보
+ <Badge variant="outline">{rfq.rfqCode || "미할당"}</Badge>
+ </CardTitle>
+ <CardDescription>
+ 요청서 기본 정보 및 자재 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 번호</div>
+ <div className="text-sm">{rfq.rfqCode || "미할당"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">자재 코드</div>
+ <div className="text-sm">{rfq.materialCode || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">품목명</div>
+ <div className="text-sm">{rfq.item?.itemName || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">마감일</div>
+ <div className="text-sm">
+ {rfq.dueDate ? formatDate(rfq.dueDate) : "N/A"}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 상태</div>
+ <div className="text-sm">{rfq.status || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">담당자</div>
+ <div className="text-sm">{rfq.createdByUser?.name || "N/A"}</div>
+ </div>
+ </div>
+ {rfq.remark && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">비고</div>
+ <div className="text-sm p-3 bg-muted rounded-md">{rfq.remark}</div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 프로젝트 기본 정보 */}
+ {rfq.biddingProject && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ 프로젝트 기본 정보
+ <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge>
+ </CardTitle>
+ <CardDescription>
+ 연결된 프로젝트의 기본 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
+ <div className="text-sm">{rfq.biddingProject.pspid || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
+ <div className="text-sm">{rfq.biddingProject.projNm || "N/A"}</div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 프로젝트 스냅샷 정보 */}
+ {projectSnapshot && (
+ <Card>
+ <CardHeader>
+ <CardTitle>프로젝트 스냅샷</CardTitle>
+ <CardDescription>
+ RFQ 생성 시점의 프로젝트 상세 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {projectSnapshot.projNo && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">공사번호</div>
+ <div className="text-sm">{projectSnapshot.projNo}</div>
+ </div>
+ )}
+ {projectSnapshot.projNm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">공사명</div>
+ <div className="text-sm">{projectSnapshot.projNm}</div>
+ </div>
+ )}
+ {projectSnapshot.estmPm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">견적 PM</div>
+ <div className="text-sm">{projectSnapshot.estmPm}</div>
+ </div>
+ )}
+ {projectSnapshot.kunnrNm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선주</div>
+ <div className="text-sm">{projectSnapshot.kunnrNm}</div>
+ </div>
+ )}
+ {projectSnapshot.cls1Nm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선급</div>
+ <div className="text-sm">{projectSnapshot.cls1Nm}</div>
+ </div>
+ )}
+ {projectSnapshot.projMsrm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">척수</div>
+ <div className="text-sm">{projectSnapshot.projMsrm}</div>
+ </div>
+ )}
+ {projectSnapshot.ptypeNm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선종</div>
+ <div className="text-sm">{projectSnapshot.ptypeNm}</div>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 시리즈 스냅샷 정보 */}
+ {seriesSnapshot && Array.isArray(seriesSnapshot) && seriesSnapshot.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle>시리즈 정보 스냅샷</CardTitle>
+ <CardDescription>
+ 프로젝트의 시리즈별 K/L 일정 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {seriesSnapshot.map((series: SeriesSnapshot, index: number) => (
+ <div key={index} className="border rounded-lg p-4 space-y-3">
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary">시리즈 {series.sersNo || index + 1}</Badge>
+ </div>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {series.klDt && (
+ <div className="space-y-1">
+ <div className="text-xs font-medium text-muted-foreground">K/L</div>
+ <div className="text-sm">{formatDateToQuarter(series.klDt)}</div>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 정보가 없는 경우 */}
+ {!projectSnapshot && !seriesSnapshot && (
+ <Card>
+ <CardContent className="text-center py-8">
+ <div className="text-muted-foreground">
+ 추가 프로젝트 상세정보가 없습니다.
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ </ScrollArea>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
new file mode 100644
index 00000000..3449dcb6
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
@@ -0,0 +1,382 @@
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { CalendarIcon, Save, Send, AlertCircle } from "lucide-react"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { formatDate, cn } from "@/lib/utils"
+import { toast } from "sonner"
+
+interface QuotationResponseTabProps {
+ quotation: {
+ id: number
+ status: string
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ remark: string | null
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ item?: {
+ itemName: string | null
+ } | null
+ } | null
+ vendor: {
+ vendorName: string
+ } | null
+ }
+}
+
+const CURRENCIES = [
+ { value: "KRW", label: "KRW (원)" },
+ { value: "USD", label: "USD (달러)" },
+ { value: "EUR", label: "EUR (유로)" },
+ { value: "JPY", label: "JPY (엔)" },
+ { value: "CNY", label: "CNY (위안)" },
+]
+
+export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
+ const [totalPrice, setTotalPrice] = useState(quotation.totalPrice?.toString() || "")
+ const [currency, setCurrency] = useState(quotation.currency || "KRW")
+ const [validUntil, setValidUntil] = useState<Date | undefined>(
+ quotation.validUntil ? new Date(quotation.validUntil) : undefined
+ )
+ const [remark, setRemark] = useState(quotation.remark || "")
+ const [isLoading, setIsLoading] = useState(false)
+ const router = useRouter()
+
+ const rfq = quotation.rfq
+ const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
+ const canSubmit = quotation.status === "Draft" && !isDueDatePassed
+ const canEdit = ["Draft", "Revised"].includes(quotation.status) && !isDueDatePassed
+
+ const handleSaveDraft = async () => {
+ setIsLoading(true)
+ try {
+ const { updateTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
+
+ const result = await updateTechSalesVendorQuotation({
+ id: quotation.id,
+ currency,
+ totalPrice,
+ validUntil: validUntil!,
+ remark,
+ updatedBy: 1 // TODO: 실제 사용자 ID로 변경
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ toast.success("임시 저장되었습니다.")
+ // 페이지 새로고침 대신 router.refresh() 사용
+ router.refresh()
+ }
+ } catch {
+ toast.error("저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleSubmit = async () => {
+ if (!totalPrice || !currency || !validUntil) {
+ toast.error("모든 필수 항목을 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
+
+ const result = await submitTechSalesVendorQuotation({
+ id: quotation.id,
+ currency,
+ totalPrice,
+ validUntil: validUntil!,
+ remark,
+ updatedBy: 1 // TODO: 실제 사용자 ID로 변경
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ toast.success("견적서가 제출되었습니다.")
+ // 페이지 새로고침 대신 router.refresh() 사용
+ router.refresh()
+ }
+ } catch {
+ toast.error("제출 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ 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 (
+ <ScrollArea className="h-full">
+ <div className="space-y-6 p-1">
+ {/* 견적서 상태 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ 견적서 상태
+ <Badge variant={getStatusBadgeVariant(quotation.status)}>
+ {getStatusLabel(quotation.status)}
+ </Badge>
+ </CardTitle>
+ <CardDescription>
+ 현재 견적서 상태 및 마감일 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">견적서 상태</div>
+ <div className="text-sm">{getStatusLabel(quotation.status)}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 마감일</div>
+ <div className="text-sm">
+ {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">남은 시간</div>
+ <div className="text-sm">
+ {isDueDatePassed ? (
+ <span className="text-destructive">마감됨</span>
+ ) : rfq?.dueDate ? (
+ <span className="text-green-600">
+ {Math.ceil((new Date(rfq.dueDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}일
+ </span>
+ ) : (
+ "N/A"
+ )}
+ </div>
+ </div>
+ </div>
+
+ {isDueDatePassed && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ RFQ 마감일이 지났습니다. 견적서를 수정하거나 제출할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {!canEdit && !isDueDatePassed && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 현재 상태에서는 견적서를 수정할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 견적 응답 폼 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>견적 응답</CardTitle>
+ <CardDescription>
+ 총 가격, 통화, 유효기간을 입력해주세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 총 가격 */}
+ <div className="space-y-2">
+ <Label htmlFor="totalPrice">
+ 총 가격 <span className="text-destructive">*</span>
+ </Label>
+ <Input
+ id="totalPrice"
+ type="number"
+ placeholder="총 가격을 입력하세요"
+ value={totalPrice}
+ onChange={(e) => setTotalPrice(e.target.value)}
+ disabled={!canEdit}
+ className="text-right"
+ />
+ </div>
+
+ {/* 통화 */}
+ <div className="space-y-2">
+ <Label htmlFor="currency">
+ 통화 <span className="text-destructive">*</span>
+ </Label>
+ <Select value={currency} onValueChange={setCurrency} disabled={!canEdit}>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {CURRENCIES.map((curr) => (
+ <SelectItem key={curr.value} value={curr.value}>
+ {curr.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 유효기간 */}
+ <div className="space-y-2">
+ <Label>
+ 견적 유효기간 <span className="text-destructive">*</span>
+ </Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left font-normal",
+ !validUntil && "text-muted-foreground"
+ )}
+ disabled={!canEdit}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {validUntil ? formatDate(validUntil) : "날짜를 선택하세요"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={validUntil}
+ onSelect={setValidUntil}
+ disabled={(date) => date < new Date()}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ {/* 비고 */}
+ <div className="space-y-2">
+ <Label htmlFor="remark">비고</Label>
+ <Textarea
+ id="remark"
+ placeholder="추가 설명이나 조건을 입력하세요"
+ value={remark}
+ onChange={(e) => setRemark(e.target.value)}
+ disabled={!canEdit}
+ rows={4}
+ />
+ </div>
+
+ {/* 액션 버튼 */}
+ {canEdit && (
+ <div className="flex gap-2 pt-4">
+ <Button
+ variant="outline"
+ onClick={handleSaveDraft}
+ disabled={isLoading}
+ className="flex-1"
+ >
+ <Save className="mr-2 h-4 w-4" />
+ 임시 저장
+ </Button>
+ {canSubmit && (
+ <Button
+ onClick={handleSubmit}
+ disabled={isLoading || !totalPrice || !currency || !validUntil}
+ className="flex-1"
+ >
+ <Send className="mr-2 h-4 w-4" />
+ 견적서 제출
+ </Button>
+ )}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 현재 견적 정보 (읽기 전용) */}
+ {quotation.totalPrice && (
+ <Card>
+ <CardHeader>
+ <CardTitle>현재 견적 정보</CardTitle>
+ <CardDescription>
+ 저장된 견적 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">총 가격</div>
+ <div className="text-lg font-semibold">
+ {parseFloat(quotation.totalPrice).toLocaleString()} {quotation.currency}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">통화</div>
+ <div className="text-sm">{quotation.currency}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">유효기간</div>
+ <div className="text-sm">
+ {quotation.validUntil ? formatDate(quotation.validUntil) : "N/A"}
+ </div>
+ </div>
+ </div>
+ {quotation.remark && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">비고</div>
+ <div className="text-sm p-3 bg-muted rounded-md">{quotation.remark}</div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ </ScrollArea>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
new file mode 100644
index 00000000..a800dd95
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
@@ -0,0 +1,118 @@
+"use client"
+
+import * as React from "react"
+import { useRouter, useSearchParams } from "next/navigation"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { ProjectInfoTab } from "./project-info-tab"
+import { QuotationResponseTab } from "./quotation-response-tab"
+import { CommunicationTab } from "./communication-tab"
+
+// 프로젝트 스냅샷 타입 정의
+interface ProjectSnapshot {
+ scDt?: string
+ klDt?: string
+ lcDt?: string
+ dlDt?: string
+ dockNo?: string
+ dockNm?: string
+ projNo?: string
+ projNm?: string
+ ownerNm?: string
+ kunnrNm?: string
+ cls1Nm?: string
+ projMsrm?: number
+ ptypeNm?: string
+ sector?: string
+ estmPm?: string
+}
+
+// 시리즈 스냅샷 타입 정의
+interface SeriesSnapshot {
+ sersNo?: string
+ scDt?: string
+ klDt?: string
+ lcDt?: string
+ dlDt?: string
+ dockNo?: string
+ dockNm?: string
+}
+
+interface QuotationData {
+ id: number
+ status: string
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ remark: string | null
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ remark: string | null
+ projectSnapshot?: ProjectSnapshot | null
+ seriesSnapshot?: SeriesSnapshot[] | null
+ item?: {
+ id: number
+ itemCode: string | null
+ itemName: string | null
+ } | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: string | null
+ } | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ } | null
+}
+
+interface TechSalesQuotationTabsProps {
+ quotation: QuotationData
+ defaultTab?: string
+}
+
+export function TechSalesQuotationTabs({ quotation, defaultTab = "project" }: TechSalesQuotationTabsProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const currentTab = searchParams?.get("tab") || defaultTab
+
+ const handleTabChange = (value: string) => {
+ const params = new URLSearchParams(searchParams?.toString() || "")
+ params.set("tab", value)
+ router.push(`?${params.toString()}`, { scroll: false })
+ }
+
+ return (
+ <Tabs value={currentTab} onValueChange={handleTabChange} className="h-full flex flex-col">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="project">프로젝트 및 RFQ 정보</TabsTrigger>
+ <TabsTrigger value="quotation">견적 응답</TabsTrigger>
+ <TabsTrigger value="communication">커뮤니케이션</TabsTrigger>
+ </TabsList>
+
+ <div className="flex-1 mt-4 overflow-hidden">
+ <TabsContent value="project" className="h-full m-0">
+ <ProjectInfoTab quotation={quotation} />
+ </TabsContent>
+
+ <TabsContent value="quotation" className="h-full m-0">
+ <QuotationResponseTab quotation={quotation} />
+ </TabsContent>
+
+ <TabsContent value="communication" className="h-full m-0">
+ <CommunicationTab quotation={quotation} />
+ </TabsContent>
+ </div>
+ </Tabs>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/quotation-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-editor.tsx
new file mode 100644
index 00000000..f3fab10d
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/quotation-editor.tsx
@@ -0,0 +1,559 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import { toast } from "sonner"
+import { useRouter } from "next/navigation"
+import { CalendarIcon, Save, Send, ArrowLeft } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { DatePicker } from "@/components/ui/date-picker"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Skeleton } from "@/components/ui/skeleton"
+
+import { formatCurrency, formatDate } from "@/lib/utils"
+import {
+ updateTechSalesVendorQuotation,
+ submitTechSalesVendorQuotation,
+ fetchCurrencies
+} from "../service"
+
+// 견적서 폼 스키마 (techsales용 단순화)
+const quotationFormSchema = z.object({
+ currency: z.string().min(1, "통화를 선택해주세요"),
+ totalPrice: z.string().min(1, "총액을 입력해주세요"),
+ validUntil: z.date({
+ required_error: "견적 유효기간을 선택해주세요",
+ invalid_type_error: "유효한 날짜를 선택해주세요",
+ }),
+ remark: z.string().optional(),
+})
+
+type QuotationFormValues = z.infer<typeof quotationFormSchema>
+
+// 통화 타입
+interface Currency {
+ code: string
+ name: string
+}
+
+// 이 컴포넌트에 전달되는 견적서 데이터 타입 (techsales용 단순화)
+interface TechSalesVendorQuotation {
+ id: number
+ rfqId: number
+ vendorId: number
+ quotationCode: string | null
+ quotationVersion: number | null
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ status: "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted"
+ remark: string | null
+ rejectionReason: string | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+ rfq: {
+ id: number
+ rfqCode: string | null
+ dueDate: Date | null
+ status: string | null
+ materialCode: string | null
+ remark: string | null
+ projectSnapshot?: {
+ pspid?: string
+ projNm?: string
+ sector?: string
+ projMsrm?: number
+ kunnr?: string
+ kunnrNm?: string
+ ptypeNm?: string
+ } | null
+ seriesSnapshot?: Array<{
+ pspid: string
+ sersNo: string
+ scDt?: string
+ klDt?: string
+ lcDt?: string
+ dlDt?: string
+ dockNo?: string
+ dockNm?: string
+ projNo?: string
+ post1?: string
+ }> | null
+ item?: {
+ id: number
+ itemCode: string | null
+ itemName: string | null
+ } | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: string | null
+ } | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ }
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ }
+}
+
+interface TechSalesQuotationEditorProps {
+ quotation: TechSalesVendorQuotation
+}
+
+export default function TechSalesQuotationEditor({ quotation }: TechSalesQuotationEditorProps) {
+ const router = useRouter()
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [currencies, setCurrencies] = useState<Currency[]>([])
+ const [loadingCurrencies, setLoadingCurrencies] = useState(true)
+
+ // 폼 초기화
+ const form = useForm<QuotationFormValues>({
+ resolver: zodResolver(quotationFormSchema),
+ defaultValues: {
+ currency: quotation.currency || "USD",
+ totalPrice: quotation.totalPrice || "",
+ validUntil: quotation.validUntil || undefined,
+ remark: quotation.remark || "",
+ },
+ })
+
+ // 통화 목록 로드
+ useEffect(() => {
+ const loadCurrencies = async () => {
+ try {
+ const { data, error } = await fetchCurrencies()
+ if (error) {
+ toast.error("통화 목록을 불러오는데 실패했습니다")
+ return
+ }
+ setCurrencies(data || [])
+ } catch (error) {
+ console.error("Error loading currencies:", error)
+ toast.error("통화 목록을 불러오는데 실패했습니다")
+ } finally {
+ setLoadingCurrencies(false)
+ }
+ }
+
+ loadCurrencies()
+ }, [])
+
+ // 마감일 확인
+ const isBeforeDueDate = () => {
+ if (!quotation.rfq.dueDate) return true
+ return new Date() <= new Date(quotation.rfq.dueDate)
+ }
+
+ // 편집 가능 여부 확인
+ const isEditable = () => {
+ return quotation.status === "Draft" || quotation.status === "Rejected"
+ }
+
+ // 제출 가능 여부 확인
+ const isSubmitReady = () => {
+ const values = form.getValues()
+ return values.currency &&
+ values.totalPrice &&
+ parseFloat(values.totalPrice) > 0 &&
+ values.validUntil &&
+ isBeforeDueDate()
+ }
+
+ // 저장 핸들러
+ const handleSave = async () => {
+ if (!isEditable()) {
+ toast.error("편집할 수 없는 상태입니다")
+ return
+ }
+
+ setIsSaving(true)
+ try {
+ const values = form.getValues()
+ const { data, error } = await updateTechSalesVendorQuotation({
+ id: quotation.id,
+ currency: values.currency,
+ totalPrice: values.totalPrice,
+ validUntil: values.validUntil,
+ remark: values.remark,
+ updatedBy: quotation.vendorId, // 임시로 vendorId 사용
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ toast.success("견적서가 저장되었습니다")
+ router.refresh()
+ } catch (error) {
+ console.error("Error saving quotation:", error)
+ toast.error("견적서 저장 중 오류가 발생했습니다")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // 제출 핸들러
+ const handleSubmit = async () => {
+ if (!isEditable()) {
+ toast.error("제출할 수 없는 상태입니다")
+ return
+ }
+
+ if (!isSubmitReady()) {
+ toast.error("필수 항목을 모두 입력해주세요")
+ return
+ }
+
+ if (!isBeforeDueDate()) {
+ toast.error("마감일이 지났습니다")
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ const values = form.getValues()
+ const { data, error } = await submitTechSalesVendorQuotation({
+ id: quotation.id,
+ currency: values.currency,
+ totalPrice: values.totalPrice,
+ validUntil: values.validUntil,
+ remark: values.remark,
+ updatedBy: quotation.vendorId, // 임시로 vendorId 사용
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ toast.success("견적서가 제출되었습니다")
+ router.push("/ko/partners/techsales/rfq-ship")
+ } catch (error) {
+ console.error("Error submitting quotation:", error)
+ toast.error("견적서 제출 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 상태 배지
+ const getStatusBadge = (status: string) => {
+ const statusConfig = {
+ "Draft": { label: "초안", variant: "secondary" as const },
+ "Submitted": { label: "제출됨", variant: "default" as const },
+ "Revised": { label: "수정됨", variant: "outline" as const },
+ "Rejected": { label: "반려됨", variant: "destructive" as const },
+ "Accepted": { label: "승인됨", variant: "success" as const },
+ }
+
+ const config = statusConfig[status as keyof typeof statusConfig] || {
+ label: status,
+ variant: "secondary" as const
+ }
+
+ return <Badge variant={config.variant}>{config.label}</Badge>
+ }
+
+ return (
+ <div className="container max-w-4xl mx-auto py-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-4">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => router.back()}
+ >
+ <ArrowLeft className="h-4 w-4 mr-2" />
+ 뒤로가기
+ </Button>
+ <div>
+ <h1 className="text-2xl font-bold">기술영업 견적서</h1>
+ <p className="text-muted-foreground">
+ RFQ: {quotation.rfq.rfqCode} | {getStatusBadge(quotation.status)}
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center space-x-2">
+ {isEditable() && (
+ <>
+ <Button
+ variant="outline"
+ onClick={handleSave}
+ disabled={isSaving}
+ >
+ <Save className="h-4 w-4 mr-2" />
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isSubmitting || !isSubmitReady()}
+ >
+ <Send className="h-4 w-4 mr-2" />
+ {isSubmitting ? "제출 중..." : "제출"}
+ </Button>
+ </>
+ )}
+ </div>
+ </div>
+
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+ {/* 왼쪽: RFQ 정보 */}
+ <div className="lg:col-span-1 space-y-6">
+ {/* RFQ 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>RFQ 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">RFQ 번호</label>
+ <p className="font-mono">{quotation.rfq.rfqCode}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">자재 코드</label>
+ <p>{quotation.rfq.materialCode || "N/A"}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">품목명</label>
+ <p>{quotation.rfq.item?.itemName || "N/A"}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">마감일</label>
+ <p className={!isBeforeDueDate() ? "text-red-600 font-medium" : ""}>
+ {quotation.rfq.dueDate ? formatDate(quotation.rfq.dueDate) : "N/A"}
+ </p>
+ </div>
+ {quotation.rfq.remark && (
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">비고</label>
+ <p className="text-sm">{quotation.rfq.remark}</p>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 프로젝트 정보 */}
+ {quotation.rfq.projectSnapshot && (
+ <Card>
+ <CardHeader>
+ <CardTitle>프로젝트 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">프로젝트 번호</label>
+ <p className="font-mono">{quotation.rfq.projectSnapshot.pspid}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">프로젝트명</label>
+ <p>{quotation.rfq.projectSnapshot.projNm || "N/A"}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">선종</label>
+ <p>{quotation.rfq.projectSnapshot.ptypeNm || "N/A"}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">척수</label>
+ <p>{quotation.rfq.projectSnapshot.projMsrm || "N/A"}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">선주</label>
+ <p>{quotation.rfq.projectSnapshot.kunnrNm || "N/A"}</p>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 시리즈 정보 */}
+ {quotation.rfq.seriesSnapshot && quotation.rfq.seriesSnapshot.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle>시리즈 일정</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-3">
+ {quotation.rfq.seriesSnapshot.map((series, index) => (
+ <div key={index} className="border rounded p-3">
+ <div className="font-medium mb-2">시리즈 {series.sersNo}</div>
+ <div className="grid grid-cols-2 gap-2 text-sm">
+ {series.klDt && (
+ <div>
+ <span className="text-muted-foreground">K/L:</span> {formatDate(series.klDt)}
+ </div>
+ )}
+ {series.dlDt && (
+ <div>
+ <span className="text-muted-foreground">인도:</span> {formatDate(series.dlDt)}
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+
+ {/* 오른쪽: 견적서 입력 폼 */}
+ <div className="lg:col-span-2">
+ <Card>
+ <CardHeader>
+ <CardTitle>견적서 작성</CardTitle>
+ <CardDescription>
+ 총액 기반으로 견적을 작성해주세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <Form {...form}>
+ <form className="space-y-6">
+ {/* 통화 선택 */}
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화 *</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={!isEditable()}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {loadingCurrencies ? (
+ <div className="p-2">
+ <Skeleton className="h-4 w-full" />
+ </div>
+ ) : (
+ currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.code} - {currency.name}
+ </SelectItem>
+ ))
+ )}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 총액 입력 */}
+ <FormField
+ control={form.control}
+ name="totalPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>총액 *</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.01"
+ placeholder="총액을 입력하세요"
+ disabled={!isEditable()}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 유효기간 */}
+ <FormField
+ control={form.control}
+ name="validUntil"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>견적 유효기간 *</FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value}
+ onDateChange={field.onChange}
+ disabled={!isEditable()}
+ placeholder="유효기간을 선택하세요"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 설명이나 특이사항을 입력하세요"
+ disabled={!isEditable()}
+ rows={4}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 반려 사유 (반려된 경우에만 표시) */}
+ {quotation.status === "Rejected" && quotation.rejectionReason && (
+ <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
+ <label className="text-sm font-medium text-red-800">반려 사유</label>
+ <p className="text-sm text-red-700 mt-1">{quotation.rejectionReason}</p>
+ </div>
+ )}
+
+ {/* 제출 정보 */}
+ {quotation.submittedAt && (
+ <div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
+ <label className="text-sm font-medium text-blue-800">제출 정보</label>
+ <p className="text-sm text-blue-700 mt-1">
+ 제출일: {formatDate(quotation.submittedAt)}
+ </p>
+ </div>
+ )}
+ </form>
+ </Form>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx
new file mode 100644
index 00000000..e11864dc
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx
@@ -0,0 +1,664 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { toast } from "sonner"
+import { format } from "date-fns"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DatePicker } from "@/components/ui/date-picker"
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow
+} from "@/components/ui/table"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger
+} from "@/components/ui/tooltip"
+import {
+ Info,
+ Clock,
+ CalendarIcon,
+ ClipboardCheck,
+ AlertTriangle,
+ CheckCircle2,
+ RefreshCw,
+ Save,
+ FileText,
+ Sparkles
+} from "lucide-react"
+
+import { formatCurrency } from "@/lib/utils"
+import { updateQuotationItem } from "../services"
+import { Textarea } from "@/components/ui/textarea"
+
+// 견적 아이템 타입
+interface QuotationItem {
+ id: number
+ quotationId: number
+ prItemId: number
+ materialCode: string | null
+ materialDescription: string | null
+ quantity: number
+ uom: string | null
+ unitPrice: number
+ totalPrice: number
+ currency: string
+ vendorMaterialCode: string | null
+ vendorMaterialDescription: string | null
+ deliveryDate: Date | null
+ leadTimeInDays: number | null
+ taxRate: number | null
+ taxAmount: number | null
+ discountRate: number | null
+ discountAmount: number | null
+ remark: string | null
+ isAlternative: boolean
+ isRecommended: boolean // 남겨두지만 UI에서는 사용하지 않음
+ createdAt: Date
+ updatedAt: Date
+ prItem?: {
+ id: number
+ materialCode: string | null
+ materialDescription: string | null
+ // 기타 필요한 정보
+ }
+}
+
+// debounce 함수 구현
+function debounce<T extends (...args: any[]) => any>(
+ func: T,
+ wait: number
+): (...args: Parameters<T>) => void {
+ let timeout: NodeJS.Timeout | null = null;
+
+ return function (...args: Parameters<T>) {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => func(...args), wait);
+ };
+}
+
+interface QuotationItemEditorProps {
+ items: QuotationItem[]
+ onItemsChange: (items: QuotationItem[]) => void
+ disabled?: boolean
+ currency: string
+}
+
+export function QuotationItemEditor({
+ items,
+ onItemsChange,
+ disabled = false,
+ currency
+}: QuotationItemEditorProps) {
+ const [editingItem, setEditingItem] = useState<number | null>(null)
+ const [isSaving, setIsSaving] = useState(false)
+
+ // 저장이 필요한 항목들을 추적
+ const [pendingChanges, setPendingChanges] = useState<Set<number>>(new Set())
+
+ // 로컬 상태 업데이트 함수 - 화면에 즉시 반영하지만 서버에는 즉시 저장하지 않음
+ const updateLocalItem = <K extends keyof QuotationItem>(
+ index: number,
+ field: K,
+ value: QuotationItem[K]
+ ) => {
+ // 로컬 상태 업데이트
+ const updatedItems = [...items]
+ const item = { ...updatedItems[index] }
+
+ // 필드 업데이트
+ item[field] = value
+
+ // 대체품 체크 해제 시 관련 필드 초기화
+ if (field === 'isAlternative' && value === false) {
+ item.vendorMaterialCode = null;
+ item.vendorMaterialDescription = null;
+ item.remark = null;
+ }
+
+ // 단가나 수량이 변경되면 총액 계산
+ if (field === 'unitPrice' || field === 'quantity') {
+ item.totalPrice = Number(item.unitPrice) * Number(item.quantity)
+
+ // 세금이 있으면 세액 계산
+ if (item.taxRate) {
+ item.taxAmount = item.totalPrice * (item.taxRate / 100)
+ }
+
+ // 할인이 있으면 할인액 계산
+ if (item.discountRate) {
+ item.discountAmount = item.totalPrice * (item.discountRate / 100)
+ }
+ }
+
+ // 세율이 변경되면 세액 계산
+ if (field === 'taxRate') {
+ item.taxAmount = item.totalPrice * (value as number / 100)
+ }
+
+ // 할인율이 변경되면 할인액 계산
+ if (field === 'discountRate') {
+ item.discountAmount = item.totalPrice * (value as number / 100)
+ }
+
+ // 변경된 아이템으로 교체
+ updatedItems[index] = item
+
+ // 미저장 항목으로 표시
+ setPendingChanges(prev => new Set(prev).add(item.id))
+
+ // 부모 컴포넌트에 변경 사항 알림
+ onItemsChange(updatedItems)
+
+ // 저장 필요함을 표시
+ return item
+ }
+
+ // 서버에 저장하는 함수
+ const saveItemToServer = async (item: QuotationItem, field: keyof QuotationItem, value: any) => {
+ if (disabled) return
+
+ try {
+ setIsSaving(true)
+
+ const result = await updateQuotationItem({
+ id: item.id,
+ [field]: value,
+ totalPrice: item.totalPrice,
+ taxAmount: item.taxAmount ?? 0,
+ discountAmount: item.discountAmount ?? 0
+ })
+
+ // 저장 완료 후 pendingChanges에서 제거
+ setPendingChanges(prev => {
+ const newSet = new Set(prev)
+ newSet.delete(item.id)
+ return newSet
+ })
+
+ if (!result.success) {
+ toast.error(result.message || "항목 저장 중 오류가 발생했습니다")
+ }
+ } catch (error) {
+ console.error("항목 저장 오류:", error)
+ toast.error("항목 저장 중 오류가 발생했습니다")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // debounce된 저장 함수
+ const debouncedSave = useRef(debounce(
+ (item: QuotationItem, field: keyof QuotationItem, value: any) => {
+ saveItemToServer(item, field, value)
+ },
+ 800 // 800ms 지연
+ )).current
+
+ // 견적 항목 업데이트 함수
+ const handleItemUpdate = (index: number, field: keyof QuotationItem, value: any) => {
+ const updatedItem = updateLocalItem(index, field, value)
+
+ // debounce를 통해 서버 저장 지연
+ if (!disabled) {
+ debouncedSave(updatedItem, field, value)
+ }
+ }
+
+ // 모든 변경 사항 저장
+ const saveAllChanges = async () => {
+ if (disabled || pendingChanges.size === 0) return
+
+ setIsSaving(true)
+ toast.info(`${pendingChanges.size}개 항목 저장 중...`)
+
+ try {
+ // 변경된 모든 항목 저장
+ for (const itemId of pendingChanges) {
+ const index = items.findIndex(item => item.id === itemId)
+ if (index !== -1) {
+ const item = items[index]
+ await updateQuotationItem({
+ id: item.id,
+ unitPrice: item.unitPrice,
+ totalPrice: item.totalPrice,
+ taxRate: item.taxRate ?? 0,
+ taxAmount: item.taxAmount ?? 0,
+ discountRate: item.discountRate ?? 0,
+ discountAmount: item.discountAmount ?? 0,
+ deliveryDate: item.deliveryDate,
+ leadTimeInDays: item.leadTimeInDays ?? 0,
+ vendorMaterialCode: item.vendorMaterialCode ?? "",
+ vendorMaterialDescription: item.vendorMaterialDescription ?? "",
+ isAlternative: item.isAlternative,
+ isRecommended: false, // 항상 false로 설정 (사용하지 않음)
+ remark: item.remark ?? ""
+ })
+ }
+ }
+
+ // 모든 변경 사항 저장 완료
+ setPendingChanges(new Set())
+ toast.success("모든 변경 사항이 저장되었습니다")
+ } catch (error) {
+ console.error("변경 사항 저장 오류:", error)
+ toast.error("변경 사항 저장 중 오류가 발생했습니다")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // blur 이벤트로 저장 트리거 (사용자가 입력 완료 후)
+ const handleBlur = (index: number, field: keyof QuotationItem, value: any) => {
+ const itemId = items[index].id
+
+ // 해당 항목이 pendingChanges에 있다면 즉시 저장
+ if (pendingChanges.has(itemId)) {
+ const item = items[index]
+ saveItemToServer(item, field, value)
+ }
+ }
+
+ // 전체 단가 업데이트 (일괄 반영)
+ const handleBulkUnitPriceUpdate = () => {
+ if (items.length === 0) return
+
+ // 첫 번째 아이템의 단가 가져오기
+ const firstUnitPrice = items[0].unitPrice
+
+ if (!firstUnitPrice) {
+ toast.error("첫 번째 항목의 단가를 먼저 입력해주세요")
+ return
+ }
+
+ // 모든 아이템에 동일한 단가 적용
+ const updatedItems = items.map(item => ({
+ ...item,
+ unitPrice: firstUnitPrice,
+ totalPrice: firstUnitPrice * item.quantity,
+ taxAmount: item.taxRate ? (firstUnitPrice * item.quantity) * (item.taxRate / 100) : item.taxAmount,
+ discountAmount: item.discountRate ? (firstUnitPrice * item.quantity) * (item.discountRate / 100) : item.discountAmount
+ }))
+
+ // 모든 아이템을 변경 필요 항목으로 표시
+ setPendingChanges(new Set(updatedItems.map(item => item.id)))
+
+ // 부모 컴포넌트에 변경 사항 알림
+ onItemsChange(updatedItems)
+
+ toast.info("모든 항목의 단가가 업데이트되었습니다. 변경 사항을 저장하려면 '저장' 버튼을 클릭하세요.")
+ }
+
+ // 입력 핸들러
+ const handleNumberInputChange = (
+ index: number,
+ field: keyof QuotationItem,
+ e: React.ChangeEvent<HTMLInputElement>
+ ) => {
+ const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
+ handleItemUpdate(index, field, value)
+ }
+
+ const handleTextInputChange = (
+ index: number,
+ field: keyof QuotationItem,
+ e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+ ) => {
+ handleItemUpdate(index, field, e.target.value)
+ }
+
+ const handleDateChange = (
+ index: number,
+ field: keyof QuotationItem,
+ date: Date | undefined
+ ) => {
+ handleItemUpdate(index, field, date || null)
+ }
+
+ const handleCheckboxChange = (
+ index: number,
+ field: keyof QuotationItem,
+ checked: boolean
+ ) => {
+ handleItemUpdate(index, field, checked)
+ }
+
+ // 날짜 형식 지정
+ const formatDeliveryDate = (date: Date | null) => {
+ if (!date) return "-"
+ return format(date, "yyyy-MM-dd")
+ }
+
+ // 입력 폼 필드 렌더링
+ const renderInputField = (item: QuotationItem, index: number, field: keyof QuotationItem) => {
+ if (field === 'unitPrice' || field === 'taxRate' || field === 'discountRate' || field === 'leadTimeInDays') {
+ return (
+ <Input
+ type="number"
+ min={0}
+ step={field === 'unitPrice' ? 0.01 : field === 'taxRate' || field === 'discountRate' ? 0.1 : 1}
+ value={item[field] as number || 0}
+ onChange={(e) => handleNumberInputChange(index, field, e)}
+ onBlur={(e) => handleBlur(index, field, parseFloat(e.target.value) || 0)}
+ disabled={disabled || isSaving}
+ className="w-full"
+ />
+ )
+ } else if (field === 'vendorMaterialCode' || field === 'vendorMaterialDescription') {
+ return (
+ <Input
+ type="text"
+ value={item[field] as string || ''}
+ onChange={(e) => handleTextInputChange(index, field, e)}
+ onBlur={(e) => handleBlur(index, field, e.target.value)}
+ disabled={disabled || isSaving || !item.isAlternative}
+ className="w-full"
+ placeholder={field === 'vendorMaterialCode' ? "벤더 자재코드" : "벤더 자재명"}
+ />
+ )
+ } else if (field === 'deliveryDate') {
+ return (
+ <DatePicker
+ date={item.deliveryDate ? new Date(item.deliveryDate) : undefined}
+ onSelect={(date) => {
+ handleDateChange(index, field, date);
+ // DatePicker는 blur 이벤트가 없으므로 즉시 저장 트리거
+ if (date) handleBlur(index, field, date);
+ }}
+ disabled={disabled || isSaving}
+ />
+ )
+ } else if (field === 'isAlternative') {
+ return (
+ <div className="flex items-center gap-1">
+ <Checkbox
+ checked={item.isAlternative}
+ onCheckedChange={(checked) => {
+ handleCheckboxChange(index, field, checked as boolean);
+ handleBlur(index, field, checked as boolean);
+ }}
+ disabled={disabled || isSaving}
+ />
+ <span className="text-xs">대체품</span>
+ </div>
+ )
+ }
+
+ return null
+ }
+
+ // 대체품 필드 렌더링
+ const renderAlternativeFields = (item: QuotationItem, index: number) => {
+ if (!item.isAlternative) return null;
+
+ return (
+ <div className="mt-2 p-3 bg-blue-50 rounded-md space-y-2 text-sm">
+ {/* <div className="flex flex-col gap-2">
+ <label className="text-xs font-medium text-blue-700">벤더 자재코드</label>
+ <Input
+ value={item.vendorMaterialCode || ""}
+ onChange={(e) => handleTextInputChange(index, 'vendorMaterialCode', e)}
+ onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)}
+ disabled={disabled || isSaving}
+ className="h-8 text-sm"
+ placeholder="벤더 자재코드 입력"
+ />
+ </div> */}
+
+ <div className="flex flex-col gap-2">
+ <label className="text-xs font-medium text-blue-700">벤더 자재명</label>
+ <Input
+ value={item.vendorMaterialDescription || ""}
+ onChange={(e) => handleTextInputChange(index, 'vendorMaterialDescription', e)}
+ onBlur={(e) => handleBlur(index, 'vendorMaterialDescription', e.target.value)}
+ disabled={disabled || isSaving}
+ className="h-8 text-sm"
+ placeholder="벤더 자재명 입력"
+ />
+ </div>
+
+ <div className="flex flex-col gap-2">
+ <label className="text-xs font-medium text-blue-700">대체품 설명</label>
+ <Textarea
+ value={item.remark || ""}
+ onChange={(e) => handleTextInputChange(index, 'remark', e)}
+ onBlur={(e) => handleBlur(index, 'remark', e.target.value)}
+ disabled={disabled || isSaving}
+ className="min-h-[60px] text-sm"
+ placeholder="원본과의 차이점, 대체 사유, 장점 등을 설명해주세요"
+ />
+ </div>
+ </div>
+ );
+ };
+
+ // 항목의 저장 상태 아이콘 표시
+ const renderSaveStatus = (itemId: number) => {
+ if (pendingChanges.has(itemId)) {
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <RefreshCw className="h-4 w-4 text-yellow-500 animate-spin" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>저장되지 않은 변경 사항이 있습니다</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )
+ }
+
+ return null
+ }
+
+ return (
+ <div className="space-y-4">
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2">
+ <h3 className="text-lg font-medium">항목 목록 ({items.length}개)</h3>
+ {pendingChanges.size > 0 && (
+ <Badge variant="outline" className="bg-yellow-50">
+ 변경 {pendingChanges.size}개
+ </Badge>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2">
+ {pendingChanges.size > 0 && !disabled && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={saveAllChanges}
+ disabled={isSaving}
+ >
+ {isSaving ? (
+ <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
+ ) : (
+ <Save className="h-4 w-4 mr-2" />
+ )}
+ 변경사항 저장 ({pendingChanges.size}개)
+ </Button>
+ )}
+
+ {!disabled && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkUnitPriceUpdate}
+ disabled={items.length === 0 || isSaving}
+ >
+ 첫 항목 단가로 일괄 적용
+ </Button>
+ )}
+ </div>
+ </div>
+
+ <ScrollArea className="h-[500px] rounded-md border">
+ <Table>
+ <TableHeader className="sticky top-0 bg-background">
+ <TableRow>
+ <TableHead className="w-[50px]">번호</TableHead>
+ <TableHead>자재코드</TableHead>
+ <TableHead>자재명</TableHead>
+ <TableHead>수량</TableHead>
+ <TableHead>단위</TableHead>
+ <TableHead>단가</TableHead>
+ <TableHead>금액</TableHead>
+ <TableHead>
+ <div className="flex items-center gap-1">
+ 세율(%)
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Info className="h-4 w-4" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>세율을 입력하면 자동으로 세액이 계산됩니다.</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ </TableHead>
+ <TableHead>
+ <div className="flex items-center gap-1">
+ 납품일
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Info className="h-4 w-4" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>납품 가능한 날짜를 선택해주세요.</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ </TableHead>
+ <TableHead>리드타임(일)</TableHead>
+ <TableHead>
+ <div className="flex items-center gap-1">
+ 대체품
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Info className="h-4 w-4" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>요청된 제품의 대체품을 제안할 경우 선택하세요.</p>
+ <p>대체품을 선택하면 추가 정보를 입력할 수 있습니다.</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ </TableHead>
+ <TableHead className="w-[50px]">상태</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {items.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={12} className="text-center py-10">
+ 견적 항목이 없습니다
+ </TableCell>
+ </TableRow>
+ ) : (
+ items.map((item, index) => (
+ <React.Fragment key={item.id}>
+ <TableRow className={pendingChanges.has(item.id) ? "bg-yellow-50/30" : ""}>
+ <TableCell>
+ {index + 1}
+ </TableCell>
+ <TableCell>
+ {item.materialCode || "-"}
+ </TableCell>
+ <TableCell>
+ <div className="font-medium max-w-xs truncate">
+ {item.materialDescription || "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ {item.quantity}
+ </TableCell>
+ <TableCell>
+ {item.uom || "-"}
+ </TableCell>
+ <TableCell>
+ {renderInputField(item, index, 'unitPrice')}
+ </TableCell>
+ <TableCell>
+ {formatCurrency(item.totalPrice, currency)}
+ </TableCell>
+ <TableCell>
+ {renderInputField(item, index, 'taxRate')}
+ </TableCell>
+ <TableCell>
+ {renderInputField(item, index, 'deliveryDate')}
+ </TableCell>
+ <TableCell>
+ {renderInputField(item, index, 'leadTimeInDays')}
+ </TableCell>
+ <TableCell>
+ {renderInputField(item, index, 'isAlternative')}
+ </TableCell>
+ <TableCell>
+ {renderSaveStatus(item.id)}
+ </TableCell>
+ </TableRow>
+
+ {/* 대체품으로 선택된 경우 추가 정보 행 표시 */}
+ {item.isAlternative && (
+ <TableRow className={pendingChanges.has(item.id) ? "bg-blue-50/40" : "bg-blue-50/20"}>
+ <TableCell colSpan={1}></TableCell>
+ <TableCell colSpan={10}>
+ {renderAlternativeFields(item, index)}
+ </TableCell>
+ <TableCell colSpan={1}></TableCell>
+ </TableRow>
+ )}
+ </React.Fragment>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+
+ {isSaving && (
+ <div className="flex items-center justify-center text-sm text-muted-foreground">
+ <Clock className="h-4 w-4 animate-spin mr-2" />
+ 변경 사항을 저장 중입니다...
+ </div>
+ )}
+
+ <div className="bg-muted p-4 rounded-md">
+ <h4 className="text-sm font-medium mb-2">안내 사항</h4>
+ <ul className="text-sm space-y-1 text-muted-foreground">
+ <li className="flex items-start gap-2">
+ <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
+ <span>단가와 납품일은 필수로 입력해야 합니다.</span>
+ </li>
+ <li className="flex items-start gap-2">
+ <ClipboardCheck className="h-4 w-4 mt-0.5 flex-shrink-0" />
+ <span>입력 후 다른 필드로 이동하면 자동으로 저장됩니다. 여러 항목을 변경한 후 '저장' 버튼을 사용할 수도 있습니다.</span>
+ </li>
+ <li className="flex items-start gap-2">
+ <FileText className="h-4 w-4 mt-0.5 flex-shrink-0" />
+ <span><strong>대체품</strong>으로 제안하는 경우 자재명, 대체품 설명을 입력해주세요.</span>
+ </li>
+ </ul>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
new file mode 100644
index 00000000..5c6971cc
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -0,0 +1,365 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Edit } from "lucide-react"
+import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import {
+ TechSalesVendorQuotations,
+ TECH_SALES_QUOTATION_STATUS_CONFIG
+} from "@/db/schema"
+import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
+
+interface QuotationWithRfqCode extends TechSalesVendorQuotations {
+ rfqCode?: string;
+ materialCode?: string;
+ dueDate?: Date;
+ rfqStatus?: string;
+ itemName?: string;
+ projNm?: string;
+ quotationCode?: string | null;
+ quotationVersion?: number | null;
+ rejectionReason?: string | null;
+ acceptedAt?: Date | null;
+}
+
+interface GetColumnsProps {
+ router: AppRouterInstance;
+}
+
+export function getColumns({ router }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "id",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="ID" />
+ ),
+ cell: ({ row }) => (
+ <div className="w-20">
+ <span className="font-mono text-xs">{row.getValue("id")}</span>
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="RFQ 번호" />
+ ),
+ cell: ({ row }) => {
+ const rfqCode = row.getValue("rfqCode") as string;
+ return (
+ <div className="min-w-32">
+ <span className="font-mono text-sm">{rfqCode || "N/A"}</span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "materialCode",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="자재 코드" />
+ ),
+ cell: ({ row }) => {
+ const materialCode = row.getValue("materialCode") as string;
+ return (
+ <div className="min-w-32">
+ <span className="font-mono text-sm">{materialCode || "N/A"}</span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="품목명" />
+ ),
+ cell: ({ row }) => {
+ const itemName = row.getValue("itemName") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {itemName || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{itemName || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "projNm",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="프로젝트명" />
+ ),
+ cell: ({ row }) => {
+ const projNm = row.getValue("projNm") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {projNm || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{projNm || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string;
+
+ const statusConfig = TECH_SALES_QUOTATION_STATUS_CONFIG[status as keyof typeof TECH_SALES_QUOTATION_STATUS_CONFIG] || {
+ label: status,
+ variant: "secondary" as const
+ };
+
+ return (
+ <div className="w-24">
+ <Badge variant={statusConfig.variant} className="text-xs">
+ {statusConfig.label}
+ </Badge>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id));
+ },
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="통화" />
+ ),
+ cell: ({ row }) => {
+ const currency = row.getValue("currency") as string;
+ return (
+ <div className="w-16">
+ <span className="font-mono text-sm">{currency || "N/A"}</span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="총액" />
+ ),
+ cell: ({ row }) => {
+ const totalPrice = row.getValue("totalPrice") as string;
+ const currency = row.getValue("currency") as string;
+
+ if (!totalPrice || totalPrice === "0") {
+ return (
+ <div className="w-32 text-right">
+ <span className="text-muted-foreground text-sm">미입력</span>
+ </div>
+ );
+ }
+
+ return (
+ <div className="w-32 text-right">
+ <span className="font-mono text-sm">
+ {formatCurrency(parseFloat(totalPrice), currency || "USD")}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "validUntil",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="유효기간" />
+ ),
+ cell: ({ row }) => {
+ const validUntil = row.getValue("validUntil") as Date;
+ return (
+ <div className="w-28">
+ <span className="text-sm">
+ {validUntil ? formatDate(validUntil) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="제출일" />
+ ),
+ cell: ({ row }) => {
+ const submittedAt = row.getValue("submittedAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {submittedAt ? formatDateTime(submittedAt) : "미제출"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="마감일" />
+ ),
+ cell: ({ row }) => {
+ const dueDate = row.getValue("dueDate") as Date;
+ const isOverdue = dueDate && new Date() > new Date(dueDate);
+
+ return (
+ <div className="w-28">
+ <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
+ {dueDate ? formatDate(dueDate) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ const createdAt = row.getValue("createdAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {createdAt ? formatDateTime(createdAt) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ const updatedAt = row.getValue("updatedAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {updatedAt ? formatDateTime(updatedAt) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ id: "actions",
+ header: "작업",
+ cell: ({ row }) => {
+ const quotation = row.original;
+ const rfqCode = quotation.rfqCode || "N/A";
+ const tooltipText = `${rfqCode} 견적서 작성`;
+
+ return (
+ <div className="w-16">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => {
+ router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`);
+ }}
+ className="h-8 w-8"
+ >
+ <Edit className="h-4 w-4" />
+ <span className="sr-only">견적서 작성</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ];
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
new file mode 100644
index 00000000..63d4674b
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
@@ -0,0 +1,143 @@
+// lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+"use client"
+
+import * as React from "react"
+import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QUOTATION_STATUS_CONFIG } from "@/db/schema"
+import { useRouter } from "next/navigation"
+import { getColumns } from "./vendor-quotations-table-columns"
+
+interface QuotationWithRfqCode extends TechSalesVendorQuotations {
+ rfqCode?: string;
+ materialCode?: string;
+ dueDate?: Date;
+ rfqStatus?: string;
+ itemName?: string;
+ projNm?: string;
+ quotationCode?: string | null;
+ quotationVersion?: number | null;
+ rejectionReason?: string | null;
+ acceptedAt?: Date | null;
+}
+
+interface VendorQuotationsTableProps {
+ promises: Promise<[{ data: any[], pageCount: number, total?: number }]>;
+}
+
+export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) {
+
+ // TODO: 안정화 이후 삭제
+ console.log("렌더링 사이클 점검용 로그: VendorQuotationsTable 렌더링됨");
+
+ const [{ data, pageCount }] = React.use(promises);
+ const router = useRouter();
+
+ // 데이터 안정성을 위한 메모이제이션 - 핵심 속성만 비교
+ const stableData = React.useMemo(() => {
+ return data;
+ }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]);
+
+ // 테이블 컬럼 정의 - router는 안정적이므로 한 번만 생성
+ const columns = React.useMemo(() => getColumns({
+ router,
+ }), [router]);
+
+ // 필터 필드 - 중앙화된 상태 상수 사용
+ const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [
+ {
+ id: "status",
+ label: "상태",
+ options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
+ label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
+ value: statusValue,
+ }))
+ },
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ placeholder: "RFQ 번호 검색...",
+ },
+ {
+ id: "materialCode",
+ label: "자재 코드",
+ placeholder: "자재 코드 검색...",
+ }
+ ], []);
+
+ // 고급 필터 필드 - 중앙화된 상태 상수 사용
+ const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<QuotationWithRfqCode>[]>(() => [
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ type: "text",
+ },
+ {
+ id: "materialCode",
+ label: "자재 코드",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "상태",
+ type: "multi-select",
+ options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
+ label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
+ value: statusValue,
+ })),
+ },
+ {
+ id: "validUntil",
+ label: "유효기간",
+ type: "date",
+ },
+ {
+ id: "submittedAt",
+ label: "제출일",
+ type: "date",
+ },
+ ], []);
+
+ // useDataTable 훅 사용
+ const { table } = useDataTable({
+ data: stableData,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableColumnResizing: true,
+ columnResizeMode: 'onChange',
+ initialState: {
+ sorting: [{ id: "updatedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ defaultColumn: {
+ minSize: 50,
+ maxSize: 500,
+ },
+ });
+
+ return (
+ <div className="w-full">
+ <div className="overflow-x-auto">
+ <DataTable
+ table={table}
+ className="min-w-full"
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/lib/utils.ts b/lib/utils.ts
index c7015638..c09589d5 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -149,4 +149,37 @@ export function formatCurrency(
// minimumFractionDigits: 0,
// maximumFractionDigits: 2,
}).format(value)
+}
+
+/**
+ * YYYYMMDD 형태의 날짜를 YYYY nQ 형태의 분기로 변환
+ * @param dateString YYYYMMDD 형태의 문자열 (예: "20240315")
+ * @returns YYYY nQ 형태의 문자열 (예: "2024 1Q")
+ */
+export function formatDateToQuarter(dateString: string | null | undefined): string {
+ if (!dateString) return "-"
+
+ // YYYYMMDD 형태인지 확인
+ if (typeof dateString !== 'string' || dateString.length !== 8) {
+ return "-"
+ }
+
+ const year = dateString.substring(0, 4)
+ const month = parseInt(dateString.substring(4, 6), 10)
+
+ // 월을 분기로 변환
+ let quarter: number
+ if (month >= 1 && month <= 3) {
+ quarter = 1
+ } else if (month >= 4 && month <= 6) {
+ quarter = 2
+ } else if (month >= 7 && month <= 9) {
+ quarter = 3
+ } else if (month >= 10 && month <= 12) {
+ quarter = 4
+ } else {
+ return "-" // 잘못된 월
+ }
+
+ return `${year} ${quarter}Q`
} \ No newline at end of file
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index c9ee55be..16f57b57 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -2131,4 +2131,46 @@ export async function exportVendorDetails(vendorIds: number[]) {
console.error("Failed to export vendor details:", error);
return [];
}
+}
+
+/**
+ * 벤더 검색 (검색어 기반, 최대 100개)
+ * RFQ 벤더 추가 시 사용
+ */
+export async function searchVendors(searchTerm: string = "", limit: number = 100) {
+ try {
+ let whereCondition;
+
+ if (searchTerm.trim()) {
+ const s = `%${searchTerm.trim()}%`;
+ whereCondition = or(
+ ilike(vendorsWithTypesView.vendorName, s),
+ ilike(vendorsWithTypesView.vendorCode, s)
+ );
+ }
+
+ const vendors = await db
+ .select({
+ id: vendorsWithTypesView.id,
+ vendorName: vendorsWithTypesView.vendorName,
+ vendorCode: vendorsWithTypesView.vendorCode,
+ status: vendorsWithTypesView.status,
+ country: vendorsWithTypesView.country,
+ })
+ .from(vendorsWithTypesView)
+ .where(
+ and(
+ whereCondition,
+ // ACTIVE 상태인 벤더만 검색
+ // eq(vendorsWithTypesView.status, "ACTIVE"),
+ )
+ )
+ .orderBy(asc(vendorsWithTypesView.vendorName))
+ .limit(limit);
+
+ return vendors;
+ } catch (error) {
+ console.error("벤더 검색 오류:", error);
+ return [];
+ }
} \ No newline at end of file