diff options
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> + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({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> + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({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 |
