diff options
Diffstat (limited to 'lib/techsales-rfq/table')
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx | 403 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx | 54 |
2 files changed, 356 insertions, 101 deletions
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<typeof vendorFormSchema>
@@ -72,16 +88,21 @@ export function AddVendorDialog({ const [hasCandidatesLoaded, setHasCandidatesLoaded] = useState(false)
// 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지
const [selectedVendorData, setSelectedVendorData] = useState<VendorSearchResult[]>([])
- const [activeTab, setActiveTab] = useState("candidates")
+ const [activeTab, setActiveTab] = useState("step1")
+ const [currentStep, setCurrentStep] = useState(1)
const form = useForm<VendorFormValues>({
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({ </ScrollArea>
)
+ // 벤더 구분자 설정 UI
+ const renderVendorFlagsStep = () => (
+ <div className="space-y-4">
+ <div className="text-sm text-muted-foreground">
+ 선택된 벤더들의 구분자를 설정해주세요. 각 벤더별로 여러 구분자를 선택할 수 있습니다.
+ </div>
+
+ {selectedVendorData.length > 0 ? (
+ <div className="space-y-4">
+ {selectedVendorData.map((vendor) => (
+ <Card key={vendor.id}>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">{vendor.vendorName}</CardTitle>
+ <CardDescription>
+ {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`}
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="grid grid-cols-2 gap-3">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`customer-preferred-${vendor.id}`}
+ checked={vendorFlags[vendor.id.toString()]?.isCustomerPreferred || false}
+ onCheckedChange={(checked) =>
+ handleVendorFlagChange(vendor.id, 'isCustomerPreferred', checked as boolean)
+ }
+ />
+ <label
+ htmlFor={`customer-preferred-${vendor.id}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ 고객(선주) 선호 벤더
+ </label>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`new-discovery-${vendor.id}`}
+ checked={vendorFlags[vendor.id.toString()]?.isNewDiscovery || false}
+ onCheckedChange={(checked) =>
+ handleVendorFlagChange(vendor.id, 'isNewDiscovery', checked as boolean)
+ }
+ />
+ <label
+ htmlFor={`new-discovery-${vendor.id}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ 신규 발굴 벤더
+ </label>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`project-approved-${vendor.id}`}
+ checked={vendorFlags[vendor.id.toString()]?.isProjectApproved || false}
+ onCheckedChange={(checked) =>
+ handleVendorFlagChange(vendor.id, 'isProjectApproved', checked as boolean)
+ }
+ />
+ <label
+ htmlFor={`project-approved-${vendor.id}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ Project Approved Vendor
+ </label>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`shi-proposal-${vendor.id}`}
+ checked={vendorFlags[vendor.id.toString()]?.isShiProposal || false}
+ onCheckedChange={(checked) =>
+ handleVendorFlagChange(vendor.id, 'isShiProposal', checked as boolean)
+ }
+ />
+ <label
+ htmlFor={`shi-proposal-${vendor.id}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ SHI Proposal Vendor
+ </label>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground border rounded-md">
+ 선택된 벤더가 없습니다
+ </div>
+ )}
+ </div>
+ )
+
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[800px] max-h-[80vh] flex flex-col">
+ <DialogContent className="sm:max-w-[900px] max-h-[80vh] flex flex-col">
{/* 헤더 */}
<DialogHeader>
<DialogTitle>벤더 추가</DialogTitle>
@@ -313,102 +473,123 @@ export function AddVendorDialog({ </DialogDescription>
</DialogHeader>
+ {/* 단계 표시 */}
+ <div className="flex items-center justify-center space-x-4 mb-4">
+ <div className={`flex items-center space-x-2 ${currentStep >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>
+ <div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
+ currentStep >= 1 ? 'bg-primary text-primary-foreground' : 'bg-muted'
+ }`}>
+ 1
+ </div>
+ <span className="text-sm">벤더 선택</span>
+ </div>
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
+ <div className={`flex items-center space-x-2 ${currentStep >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>
+ <div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
+ currentStep >= 2 ? 'bg-primary text-primary-foreground' : 'bg-muted'
+ }`}>
+ 2
+ </div>
+ <span className="text-sm">구분자 설정</span>
+ </div>
+ </div>
+
{/* 콘텐츠 */}
<div className="flex-1 overflow-y-auto">
<Form {...form}>
<form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 숨겨진 FormField - 폼 검증을 위해 필요 */}
+ <FormField
+ control={form.control}
+ name="vendorIds"
+ render={() => <></>}
+ />
+ <FormField
+ control={form.control}
+ name="vendorFlags"
+ render={() => <></>}
+ />
+
{/* 탭 메뉴 */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="candidates">
- 후보 벤더 ({candidateVendors.length})
+ <TabsTrigger value="step1" disabled={currentStep === 2}>
+ 벤더 선택 ({selectedVendorData.length}개)
</TabsTrigger>
- <TabsTrigger value="search">
- 벤더 검색
+ <TabsTrigger value="step2" disabled={currentStep === 1}>
+ 구분자 설정
</TabsTrigger>
</TabsList>
- {/* 후보 벤더 탭 */}
- <TabsContent value="candidates" className="space-y-4">
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <label className="text-sm font-medium">추천 후보 벤더</label>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => {
- setHasCandidatesLoaded(false)
- loadCandidateVendors()
- }}
- disabled={isLoadingCandidates}
- >
- {isLoadingCandidates ? (
- <Loader2 className="h-4 w-4 animate-spin" />
- ) : (
- "새로고침"
- )}
- </Button>
- </div>
-
- {isLoadingCandidates ? (
- <div className="h-60 border rounded-md flex items-center justify-center">
- <div className="flex items-center gap-2">
- <Loader2 className="h-4 w-4 animate-spin" />
- <span>후보 벤더를 불러오는 중...</span>
- </div>
+ {/* 1단계: 벤더 선택 탭 */}
+ <TabsContent value="step1" className="space-y-4">
+ <div className="space-y-4">
+ {/* 후보 벤더 */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <label className="text-sm font-medium">추천 후보 벤더</label>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setHasCandidatesLoaded(false)
+ loadCandidateVendors()
+ }}
+ disabled={isLoadingCandidates}
+ >
+ {isLoadingCandidates ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ "새로고침"
+ )}
+ </Button>
</div>
- ) : (
- renderVendorList(candidateVendors, true)
- )}
-
- <div className="text-xs text-muted-foreground bg-blue-50 p-2 rounded">
- 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다.
- </div>
- </div>
- </TabsContent>
-
- {/* 벤더 검색 탭 */}
- <TabsContent value="search" 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" />
+
+ {isLoadingCandidates ? (
+ <div className="h-60 border rounded-md flex items-center justify-center">
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span>후보 벤더를 불러오는 중...</span>
+ </div>
+ </div>
+ ) : (
+ renderVendorList(candidateVendors, true)
)}
+
+ <div className="text-xs text-muted-foreground bg-blue-50 p-2 rounded">
+ 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다.
+ </div>
</div>
- </div>
- {/* 검색 결과 */}
- {hasSearched ? (
+ {/* 벤더 검색 */}
<div className="space-y-2">
- <div className="text-sm font-medium">
- 검색 결과 ({searchResults.length}개)
+ <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>
- {renderVendorList(searchResults)}
</div>
- ) : (
- <div className="text-center py-8 text-muted-foreground border rounded-md">
- 벤더명 또는 벤더코드를 입력하여 검색해주세요
- </div>
- )}
- </TabsContent>
- </Tabs>
- {/* 선택된 벤더 목록 - 하단에 항상 표시 */}
- <FormField
- control={form.control}
- name="vendorIds"
- render={() => (
- <FormItem>
+ {/* 검색 결과 */}
+ {hasSearched && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">
+ 검색 결과 ({searchResults.length}개)
+ </div>
+ {renderVendorList(searchResults)}
+ </div>
+ )}
+
+ {/* 선택된 벤더 목록 */}
<div className="space-y-2">
<FormLabel>선택된 벤더 ({selectedVendorData.length}개)</FormLabel>
<div className="min-h-[60px] p-3 border rounded-md bg-muted/50">
@@ -435,17 +616,14 @@ export function AddVendorDialog({ )}
</div>
</div>
- <FormMessage />
- </FormItem>
- )}
- />
+ </div>
+ </TabsContent>
- {/* 안내 메시지
- <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
- <p>• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.</p>
- <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p>
- <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p>
- </div> */}
+ {/* 2단계: 구분자 설정 탭 */}
+ <TabsContent value="step2" className="space-y-4">
+ {renderVendorFlagsStep()}
+ </TabsContent>
+ </Tabs>
</form>
</Form>
</div>
@@ -460,13 +638,36 @@ export function AddVendorDialog({ >
취소
</Button>
- <Button
- type="submit"
- form="vendor-form"
- disabled={isSubmitting || selectedVendorIds.length === 0}
- >
- {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`}
- </Button>
+
+ {currentStep === 1 ? (
+ <Button
+ type="button"
+ onClick={handleNextStep}
+ disabled={selectedVendorIds.length === 0}
+ >
+ 다음 단계
+ <ChevronRight className="ml-2 h-4 w-4" />
+ </Button>
+ ) : (
+ <div className="flex space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handlePrevStep}
+ disabled={isSubmitting}
+ >
+ <ChevronLeft className="mr-2 h-4 w-4" />
+ 이전 단계
+ </Button>
+ <Button
+ type="submit"
+ form="vendor-form"
+ disabled={isSubmitting || selectedVendorIds.length === 0}
+ >
+ {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`}
+ </Button>
+ </div>
+ )}
</DialogFooter>
</DialogContent>
</Dialog>
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 }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 구분자" />
+ ),
+ cell: ({ row }) => {
+ const vendorFlags = row.original.vendorFlags;
+
+ if (!vendorFlags) {
+ return <div className="text-muted-foreground">-</div>;
+ }
+
+ 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 <div className="text-muted-foreground">-</div>;
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {activeFlags.map((flag) => (
+ <Badge key={flag.key} variant={flag.variant} className="text-xs">
+ {flag.label}
+ </Badge>
+ ))}
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "벤더 구분자"
+ },
+ enableResizing: true,
+ size: 200,
+ },
{
accessorKey: "totalPrice",
header: ({ column }) => (
|
