From 459873f983cf1468f778109df4c7953c5d40743d Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 4 Aug 2025 09:40:21 +0000 Subject: (최겸) 기술영업 요구사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/techsales-rfq/repository.ts | 3 +- lib/techsales-rfq/service.ts | 7 + .../table/detail-table/add-vendor-dialog.tsx | 403 +++++++++++++++------ .../table/detail-table/rfq-detail-column.tsx | 54 +++ 4 files changed, 365 insertions(+), 102 deletions(-) (limited to 'lib') diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts index abf831c1..e41982b9 100644 --- a/lib/techsales-rfq/repository.ts +++ b/lib/techsales-rfq/repository.ts @@ -262,7 +262,8 @@ export async function selectTechSalesVendorQuotationsWithJoin( remark: techSalesVendorQuotations.remark, quotationVersion: techSalesVendorQuotations.quotationVersion, rejectionReason: techSalesVendorQuotations.rejectionReason, - + vendorFlags: techSalesVendorQuotations.vendorFlags, + // 날짜 정보 submittedAt: techSalesVendorQuotations.submittedAt, acceptedAt: techSalesVendorQuotations.acceptedAt, diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index afbd2f55..e3543752 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -2913,6 +2913,12 @@ function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string export async function addTechVendorsToTechSalesRfq(input: { rfqId: number; vendorIds: number[]; + vendorFlags?: Record; createdBy: number; }) { unstable_noStore(); @@ -2972,6 +2978,7 @@ export async function addTechVendorsToTechSalesRfq(input: { vendorId: vendorId, status: "Assigned", // Draft가 아닌 Assigned 상태로 생성 quotationVersion: null, // 리비전은 견적 제출 시에만 생성 + vendorFlags: input.vendorFlags?.[vendorId.toString()] || null, // 벤더 구분자 정보 추가 createdBy: input.createdBy, updatedBy: input.createdBy, }) diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx index 69953217..ea982407 100644 --- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx @@ -6,21 +6,37 @@ 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, Star } from "lucide-react" +import { Check, X, Search, Loader2, Star, ChevronRight, ChevronLeft } 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 { Form, FormField, FormLabel } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { ScrollArea } from "@/components/ui/scroll-area" import { Badge } from "@/components/ui/badge" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Checkbox } from "@/components/ui/checkbox" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { addTechVendorsToTechSalesRfq, getTechSalesRfqCandidateVendors, searchTechVendors } from "@/lib/techsales-rfq/service" -// 폼 유효성 검증 스키마 - 간단화 +// 벤더 구분자 타입 정의 +type VendorFlags = { + isCustomerPreferred?: boolean; // 고객(선주) 선호 벤더 + isNewDiscovery?: boolean; // 신규 발굴 벤더 + isProjectApproved?: boolean; // Project Approved Vendor + isShiProposal?: boolean; // SHI Proposal Vendor +} + +// 폼 유효성 검증 스키마 - 벤더 선택 + 구분자 정보 const vendorFormSchema = z.object({ vendorIds: z.array(z.number()).min(1, "최소 하나의 벤더를 선택해주세요"), + vendorFlags: z.record(z.string(), z.object({ + isCustomerPreferred: z.boolean().optional(), + isNewDiscovery: z.boolean().optional(), + isProjectApproved: z.boolean().optional(), + isShiProposal: z.boolean().optional(), + })), }) type VendorFormValues = z.infer @@ -72,16 +88,21 @@ export function AddVendorDialog({ const [hasCandidatesLoaded, setHasCandidatesLoaded] = useState(false) // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지 const [selectedVendorData, setSelectedVendorData] = useState([]) - const [activeTab, setActiveTab] = useState("candidates") + const [activeTab, setActiveTab] = useState("step1") + const [currentStep, setCurrentStep] = useState(1) const form = useForm({ resolver: zodResolver(vendorFormSchema), defaultValues: { vendorIds: [], + vendorFlags: {}, }, }) const selectedVendorIds = form.watch("vendorIds") + const vendorFlags = form.watch("vendorFlags") + + // 후보 벤더 로드 함수 const loadCandidateVendors = useCallback(async () => { @@ -166,6 +187,11 @@ export function AddVendorDialog({ const newSelectedData = selectedVendorData.filter(v => v.id !== vendor.id) form.setValue("vendorIds", newIds, { shouldValidate: true }) setSelectedVendorData(newSelectedData) + + // 구분자 정보도 제거 + const currentFlags = form.getValues("vendorFlags") + const { [vendor.id.toString()]: _, ...newFlags } = currentFlags + form.setValue("vendorFlags", newFlags) } else { // 선택 추가 const newIds = [...currentIds, vendor.id] @@ -182,6 +208,41 @@ export function AddVendorDialog({ const newSelectedData = selectedVendorData.filter(v => v.id !== vendorId) form.setValue("vendorIds", newIds, { shouldValidate: true }) setSelectedVendorData(newSelectedData) + + // 구분자 정보도 제거 + const currentFlags = form.getValues("vendorFlags") + const { [vendorId.toString()]: _, ...newFlags } = currentFlags + form.setValue("vendorFlags", newFlags) + } + + // 벤더 구분자 변경 핸들러 + const handleVendorFlagChange = (vendorId: number, flagKey: keyof VendorFlags, checked: boolean) => { + const currentFlags = form.getValues("vendorFlags") + const vendorFlags = currentFlags[vendorId.toString()] || {} + + form.setValue("vendorFlags", { + ...currentFlags, + [vendorId.toString()]: { + ...vendorFlags, + [flagKey]: checked, + } + }) + } + + // 다음 단계로 이동 + const handleNextStep = () => { + if (selectedVendorIds.length === 0) { + toast.error("최소 하나의 벤더를 선택해주세요") + return + } + setCurrentStep(2) + setActiveTab("step2") + } + + // 이전 단계로 이동 + const handlePrevStep = () => { + setCurrentStep(1) + setActiveTab("step1") } // 폼 제출 핸들러 @@ -199,10 +260,11 @@ export function AddVendorDialog({ try { setIsSubmitting(true) - // 새로운 서비스 함수 호출 + // 새로운 서비스 함수 호출 (구분자 정보 포함) const result = await addTechVendorsToTechSalesRfq({ rfqId: selectedRfq.id, vendorIds: values.vendorIds, + vendorFlags: values.vendorFlags, // 구분자 정보 추가 createdBy: Number(session.user.id), }) @@ -220,6 +282,8 @@ export function AddVendorDialog({ setHasSearched(false) setHasCandidatesLoaded(false) setSelectedVendorData([]) + setCurrentStep(1) + setActiveTab("step1") onSuccess?.() } } catch (error) { @@ -240,7 +304,8 @@ export function AddVendorDialog({ setHasSearched(false) setHasCandidatesLoaded(false) setSelectedVendorData([]) - setActiveTab("candidates") + setCurrentStep(1) + setActiveTab("step1") } }, [open, form]) @@ -296,9 +361,104 @@ export function AddVendorDialog({ ) + // 벤더 구분자 설정 UI + const renderVendorFlagsStep = () => ( +
+
+ 선택된 벤더들의 구분자를 설정해주세요. 각 벤더별로 여러 구분자를 선택할 수 있습니다. +
+ + {selectedVendorData.length > 0 ? ( +
+ {selectedVendorData.map((vendor) => ( + + + {vendor.vendorName} + + {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} + + + +
+
+ + handleVendorFlagChange(vendor.id, 'isCustomerPreferred', checked as boolean) + } + /> + +
+ +
+ + handleVendorFlagChange(vendor.id, 'isNewDiscovery', checked as boolean) + } + /> + +
+ +
+ + handleVendorFlagChange(vendor.id, 'isProjectApproved', checked as boolean) + } + /> + +
+ +
+ + handleVendorFlagChange(vendor.id, 'isShiProposal', checked as boolean) + } + /> + +
+
+
+
+ ))} +
+ ) : ( +
+ 선택된 벤더가 없습니다 +
+ )} +
+ ) + return ( - + {/* 헤더 */} 벤더 추가 @@ -313,102 +473,123 @@ export function AddVendorDialog({ + {/* 단계 표시 */} +
+
= 1 ? 'text-primary' : 'text-muted-foreground'}`}> +
= 1 ? 'bg-primary text-primary-foreground' : 'bg-muted' + }`}> + 1 +
+ 벤더 선택 +
+ +
= 2 ? 'text-primary' : 'text-muted-foreground'}`}> +
= 2 ? 'bg-primary text-primary-foreground' : 'bg-muted' + }`}> + 2 +
+ 구분자 설정 +
+
+ {/* 콘텐츠 */}
+ {/* 숨겨진 FormField - 폼 검증을 위해 필요 */} + <>} + /> + <>} + /> + {/* 탭 메뉴 */} - - 후보 벤더 ({candidateVendors.length}) + + 벤더 선택 ({selectedVendorData.length}개) - - 벤더 검색 + + 구분자 설정 - {/* 후보 벤더 탭 */} - -
-
- - -
- - {isLoadingCandidates ? ( -
-
- - 후보 벤더를 불러오는 중... -
+ {/* 1단계: 벤더 선택 탭 */} + +
+ {/* 후보 벤더 */} +
+
+ +
- ) : ( - renderVendorList(candidateVendors, true) - )} - -
- 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다. -
-
- - - {/* 벤더 검색 탭 */} - - {/* 벤더 검색 필드 */} -
- -
- - setSearchTerm(e.target.value)} - className="pl-10" - /> - {isSearching && ( - + + {isLoadingCandidates ? ( +
+
+ + 후보 벤더를 불러오는 중... +
+
+ ) : ( + renderVendorList(candidateVendors, true) )} + +
+ 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다. +
-
- {/* 검색 결과 */} - {hasSearched ? ( + {/* 벤더 검색 */}
-
- 검색 결과 ({searchResults.length}개) + +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> + {isSearching && ( + + )}
- {renderVendorList(searchResults)}
- ) : ( -
- 벤더명 또는 벤더코드를 입력하여 검색해주세요 -
- )} - - - {/* 선택된 벤더 목록 - 하단에 항상 표시 */} - ( - + {/* 검색 결과 */} + {hasSearched && ( +
+
+ 검색 결과 ({searchResults.length}개) +
+ {renderVendorList(searchResults)} +
+ )} + + {/* 선택된 벤더 목록 */}
선택된 벤더 ({selectedVendorData.length}개)
@@ -435,17 +616,14 @@ export function AddVendorDialog({ )}
- -
- )} - /> +
+
- {/* 안내 메시지 -
-

• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.

-

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

-

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

-
*/} + {/* 2단계: 구분자 설정 탭 */} + + {renderVendorFlagsStep()} + +
@@ -460,13 +638,36 @@ export function AddVendorDialog({ > 취소 - + + {currentStep === 1 ? ( + + ) : ( +
+ + +
+ )}
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx index 7ece2406..65f11d0e 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx @@ -47,6 +47,12 @@ export interface RfqDetailView { quotationCode?: string | null rfqCode?: string | null quotationVersion?: number | null + vendorFlags?: { + isCustomerPreferred?: boolean; // 고객(선주) 선호 벤더 + isNewDiscovery?: boolean; // 신규 발굴 벤더 + isProjectApproved?: boolean; // Project Approved Vendor + isShiProposal?: boolean; // SHI Proposal Vendor + } | null quotationAttachments?: Array<{ id: number revisionId: number @@ -202,6 +208,54 @@ export function getRfqDetailColumns({ enableResizing: false, size: 60, }, + // [벤더 구분자 컬럼 추가] + { + id: "vendorFlags", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vendorFlags = row.original.vendorFlags; + + if (!vendorFlags) { + return
-
; + } + + const activeFlags = []; + + if (vendorFlags.isCustomerPreferred) { + activeFlags.push({ key: "isCustomerPreferred", label: "고객(선주) 선호 벤더", variant: "default" as const }); + } + if (vendorFlags.isNewDiscovery) { + activeFlags.push({ key: "isNewDiscovery", label: "신규 발굴 벤더", variant: "secondary" as const }); + } + if (vendorFlags.isProjectApproved) { + activeFlags.push({ key: "isProjectApproved", label: "Project Approved Vendor", variant: "outline" as const }); + } + if (vendorFlags.isShiProposal) { + activeFlags.push({ key: "isShiProposal", label: "SHI Proposal Vendor", variant: "destructive" as const }); + } + + if (activeFlags.length === 0) { + return
-
; + } + + return ( +
+ {activeFlags.map((flag) => ( + + {flag.label} + + ))} +
+ ); + }, + meta: { + excelHeader: "벤더 구분자" + }, + enableResizing: true, + size: 200, + }, { accessorKey: "totalPrice", header: ({ column }) => ( -- cgit v1.2.3