diff options
Diffstat (limited to 'lib/rfq-last/vendor')
| -rw-r--r-- | lib/rfq-last/vendor/add-vendor-dialog.tsx | 307 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/batch-update-conditions-dialog.tsx | 1121 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 746 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/vendor-detail-dialog.tsx | 0 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/vendor-response-status-card.tsx | 51 |
5 files changed, 2225 insertions, 0 deletions
diff --git a/lib/rfq-last/vendor/add-vendor-dialog.tsx b/lib/rfq-last/vendor/add-vendor-dialog.tsx new file mode 100644 index 00000000..d8745298 --- /dev/null +++ b/lib/rfq-last/vendor/add-vendor-dialog.tsx @@ -0,0 +1,307 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; +import { Check, ChevronsUpDown, Loader2, X, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { addVendorsToRfq } from "../service"; +import { getVendorsForSelection } from "@/lib/b-rfq/service"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Info } from "lucide-react"; + +interface AddVendorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + onSuccess: () => void; +} + +export function AddVendorDialog({ + open, + onOpenChange, + rfqId, + onSuccess, +}: AddVendorDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [vendorOpen, setVendorOpen] = React.useState(false); + const [vendorList, setVendorList] = React.useState<any[]>([]); + const [selectedVendors, setSelectedVendors] = React.useState<any[]>([]); + + // 벤더 로드 + const loadVendors = React.useCallback(async () => { + try { + const result = await getVendorsForSelection(); + if (result) { + setVendorList(result); + } + } catch (error) { + console.error("Failed to load vendors:", error); + toast.error("벤더 목록을 불러오는데 실패했습니다."); + } + }, []); + + React.useEffect(() => { + if (open) { + loadVendors(); + } + }, [open, loadVendors]); + + // 초기화 + React.useEffect(() => { + if (!open) { + setSelectedVendors([]); + } + }, [open]); + + // 벤더 추가 + const handleAddVendor = (vendor: any) => { + if (!selectedVendors.find(v => v.id === vendor.id)) { + setSelectedVendors([...selectedVendors, vendor]); + } + setVendorOpen(false); + }; + + // 벤더 제거 + const handleRemoveVendor = (vendorId: number) => { + setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId)); + }; + + // 제출 처리 - 벤더만 추가 + const handleSubmit = async () => { + if (selectedVendors.length === 0) { + toast.error("최소 1개 이상의 벤더를 선택해주세요."); + return; + } + + setIsLoading(true); + + try { + const vendorIds = selectedVendors.map(v => v.id); + const result = await addVendorsToRfq({ + rfqId, + vendorIds, + // 기본값으로 벤더만 추가 (상세 조건은 나중에 일괄 입력) + conditions: null, + }); + + if (result.success) { + toast.success( + <div> + <p>{selectedVendors.length}개 벤더가 추가되었습니다.</p> + <p className="text-sm text-muted-foreground mt-1"> + 벤더 목록에서 '정보 일괄 입력' 버튼으로 조건을 설정하세요. + </p> + </div> + ); + onSuccess(); + onOpenChange(false); + } else { + toast.error(result.error || "벤더 추가에 실패했습니다."); + } + } catch (error) { + console.error("Submit error:", error); + toast.error("오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + // 이미 선택된 벤더인지 확인 + const isVendorSelected = (vendorId: number) => { + return selectedVendors.some(v => v.id === vendorId); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl max-h-[80vh] p-0 flex flex-col"> + {/* 헤더 */} + <DialogHeader className="p-6 pb-0"> + <DialogTitle>벤더 추가</DialogTitle> + <DialogDescription> + 견적 요청을 보낼 벤더를 선택하세요. 조건 설정은 추가 후 일괄로 진행할 수 있습니다. + </DialogDescription> + </DialogHeader> + + {/* 컨텐츠 영역 */} + <div className="flex-1 px-6 py-4 overflow-y-auto"> + <div className="space-y-4"> + {/* 안내 메시지 */} + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + 여기서는 벤더만 선택합니다. 납기일, 결제조건 등의 상세 정보는 벤더 추가 후 + '정보 일괄 입력' 기능으로 한 번에 설정할 수 있습니다. + </AlertDescription> + </Alert> + + {/* 벤더 선택 카드 */} + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle className="text-lg">벤더 선택</CardTitle> + <Badge variant="outline" className="ml-2"> + {selectedVendors.length}개 선택됨 + </Badge> + </div> + <CardDescription> + RFQ를 발송할 벤더를 선택하세요. 여러 개 선택 가능합니다. + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 벤더 추가 버튼 */} + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className="w-full justify-between" + disabled={vendorList.length === 0} + > + <span className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + 벤더 선택하기 + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[500px] p-0" align="start"> + <Command> + <CommandInput placeholder="벤더명 또는 코드로 검색..." /> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {vendorList + .filter(vendor => !isVendorSelected(vendor.id)) + .map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorCode} ${vendor.vendorName}`} + onSelect={() => handleAddVendor(vendor)} + > + <div className="flex items-center gap-2 w-full"> + <Badge variant="outline" className="shrink-0"> + {vendor.vendorCode} + </Badge> + <span className="truncate">{vendor.vendorName}</span> + {vendor.country && ( + <span className="text-xs text-muted-foreground ml-auto"> + {vendor.country} + </span> + )} + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + {/* 선택된 벤더 목록 */} + {selectedVendors.length > 0 && ( + <div className="space-y-2"> + <Label className="text-sm text-muted-foreground">선택된 벤더 목록</Label> + <ScrollArea className="h-[200px] w-full rounded-md border p-4"> + <div className="space-y-2"> + {selectedVendors.map((vendor, index) => ( + <div + key={vendor.id} + className="flex items-center justify-between p-2 rounded-lg bg-secondary/50" + > + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground"> + {index + 1}. + </span> + <Badge variant="outline"> + {vendor.vendorCode} + </Badge> + <span className="text-sm font-medium"> + {vendor.vendorName} + </span> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveVendor(vendor.id)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </ScrollArea> + </div> + )} + + {/* 벤더가 없는 경우 메시지 */} + {selectedVendors.length === 0 && ( + <div className="text-center py-8 text-muted-foreground"> + <p className="text-sm">아직 선택된 벤더가 없습니다.</p> + <p className="text-xs mt-1">위 버튼을 클릭하여 벤더를 추가하세요.</p> + </div> + )} + </div> + </CardContent> + </Card> + </div> + </div> + + {/* 푸터 */} + <DialogFooter className="p-6 pt-0 border-t"> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={isLoading || selectedVendors.length === 0} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {selectedVendors.length > 0 + ? `${selectedVendors.length}개 벤더 추가` + : '벤더 추가' + } + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx new file mode 100644 index 00000000..1b8fa528 --- /dev/null +++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx @@ -0,0 +1,1121 @@ +"use client"; + +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Calendar } from "@/components/ui/calendar"; +import { CalendarIcon, Loader2, Info, Package, Check, ChevronsUpDown } from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { updateVendorConditionsBatch } from "../service"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + getIncotermsForSelection, + getPaymentTermsForSelection, + getPlaceOfShippingForSelection, + getPlaceOfDestinationForSelection +} from "@/lib/procurement-select/service"; + +interface BatchUpdateConditionsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + rfqCode: string; + selectedVendors: Array<{ + id: number; + vendorName: string; + vendorCode: string; + }>; + onSuccess: () => void; +} + +// 타입 정의 +interface SelectOption { + id: number; + code: string; + description: string; +} + +// 폼 스키마 +const formSchema = z.object({ + currency: z.string().optional(), + paymentTermsCode: z.string().optional(), + incotermsCode: z.string().optional(), + incotermsDetail: z.string().optional(), + contractDuration: z.string().optional(), + taxCode: z.string().optional(), + placeOfShipping: z.string().optional(), + placeOfDestination: z.string().optional(), + deliveryDate: z.date().optional(), + materialPriceRelatedYn: z.boolean().default(false), + sparepartYn: z.boolean().default(false), + firstYn: z.boolean().default(false), + firstDescription: z.string().optional(), + sparepartDescription: z.string().optional(), +}); + +type FormValues = z.infer<typeof formSchema>; + +const currencies = ["USD", "EUR", "KRW", "JPY", "CNY"]; + +export function BatchUpdateConditionsDialog({ + open, + onOpenChange, + rfqId, + rfqCode, + selectedVendors, + onSuccess, +}: BatchUpdateConditionsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + + // Select 옵션들 상태 + const [incoterms, setIncoterms] = React.useState<SelectOption[]>([]); + const [paymentTerms, setPaymentTerms] = React.useState<SelectOption[]>([]); + const [shippingPlaces, setShippingPlaces] = React.useState<SelectOption[]>([]); + const [destinationPlaces, setDestinationPlaces] = React.useState<SelectOption[]>([]); + + // 로딩 상태 + const [incotermsLoading, setIncotermsLoading] = React.useState(false); + const [paymentTermsLoading, setPaymentTermsLoading] = React.useState(false); + const [shippingLoading, setShippingLoading] = React.useState(false); + const [destinationLoading, setDestinationLoading] = React.useState(false); + + // Popover 열림 상태 + const [incotermsOpen, setIncotermsOpen] = React.useState(false); + const [paymentTermsOpen, setPaymentTermsOpen] = React.useState(false); + const [shippingOpen, setShippingOpen] = React.useState(false); + const [destinationOpen, setDestinationOpen] = React.useState(false); + const [calendarOpen, setCalendarOpen] = React.useState(false); + + // 체크박스로 각 필드 업데이트 여부 관리 + const [fieldsToUpdate, setFieldsToUpdate] = React.useState({ + currency: false, + paymentTermsCode: false, + incoterms: false, + deliveryDate: false, + contractDuration: false, + taxCode: false, + shipping: false, + materialPrice: false, + sparepart: false, + first: false, + }); + + // 폼 초기화 + const form = useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { + currency: "", + paymentTermsCode: "", + incotermsCode: "", + incotermsDetail: "", + contractDuration: "", + taxCode: "", + placeOfShipping: "", + placeOfDestination: "", + materialPriceRelatedYn: false, + sparepartYn: false, + firstYn: false, + firstDescription: "", + sparepartDescription: "", + }, + }); + + // 데이터 로드 함수들 + const loadIncoterms = React.useCallback(async () => { + setIncotermsLoading(true); + try { + const data = await getIncotermsForSelection(); + setIncoterms(data); + } catch (error) { + console.error("Failed to load incoterms:", error); + toast.error("Incoterms 목록을 불러오는데 실패했습니다."); + } finally { + setIncotermsLoading(false); + } + }, []); + + const loadPaymentTerms = React.useCallback(async () => { + setPaymentTermsLoading(true); + try { + const data = await getPaymentTermsForSelection(); + setPaymentTerms(data); + } catch (error) { + console.error("Failed to load payment terms:", error); + toast.error("결제조건 목록을 불러오는데 실패했습니다."); + } finally { + setPaymentTermsLoading(false); + } + }, []); + + const loadShippingPlaces = React.useCallback(async () => { + setShippingLoading(true); + try { + const data = await getPlaceOfShippingForSelection(); + setShippingPlaces(data); + } catch (error) { + console.error("Failed to load shipping places:", error); + toast.error("선적지 목록을 불러오는데 실패했습니다."); + } finally { + setShippingLoading(false); + } + }, []); + + const loadDestinationPlaces = React.useCallback(async () => { + setDestinationLoading(true); + try { + const data = await getPlaceOfDestinationForSelection(); + setDestinationPlaces(data); + } catch (error) { + console.error("Failed to load destination places:", error); + toast.error("도착지 목록을 불러오는데 실패했습니다."); + } finally { + setDestinationLoading(false); + } + }, []); + + // 초기 데이터 로드 + React.useEffect(() => { + if (open) { + loadIncoterms(); + loadPaymentTerms(); + loadShippingPlaces(); + loadDestinationPlaces(); + } + }, [open, loadIncoterms, loadPaymentTerms, loadShippingPlaces, loadDestinationPlaces]); + + // 다이얼로그 닫힐 때 초기화 + React.useEffect(() => { + if (!open) { + form.reset(); + setFieldsToUpdate({ + currency: false, + paymentTermsCode: false, + incoterms: false, + deliveryDate: false, + contractDuration: false, + taxCode: false, + shipping: false, + materialPrice: false, + sparepart: false, + first: false, + }); + } + }, [open, form]); + + // 제출 처리 + const onSubmit = async (data: FormValues) => { + const hasFieldsToUpdate = Object.values(fieldsToUpdate).some(v => v); + if (!hasFieldsToUpdate) { + toast.error("최소 1개 이상의 변경할 항목을 선택해주세요."); + return; + } + + // 선택된 필드만 포함하여 conditions 객체 생성 + const conditions: any = {}; + + if (fieldsToUpdate.currency && data.currency) { + conditions.currency = data.currency; + } + if (fieldsToUpdate.paymentTermsCode && data.paymentTermsCode) { + conditions.paymentTermsCode = data.paymentTermsCode; + } + if (fieldsToUpdate.incoterms) { + if (data.incotermsCode) conditions.incotermsCode = data.incotermsCode; + if (data.incotermsDetail) conditions.incotermsDetail = data.incotermsDetail; + } + if (fieldsToUpdate.deliveryDate && data.deliveryDate) { + conditions.deliveryDate = data.deliveryDate; + } + if (fieldsToUpdate.contractDuration) { + conditions.contractDuration = data.contractDuration; + } + if (fieldsToUpdate.taxCode) { + conditions.taxCode = data.taxCode; + } + if (fieldsToUpdate.shipping) { + conditions.placeOfShipping = data.placeOfShipping; + conditions.placeOfDestination = data.placeOfDestination; + } + if (fieldsToUpdate.materialPrice) { + conditions.materialPriceRelatedYn = data.materialPriceRelatedYn; + } + if (fieldsToUpdate.sparepart) { + conditions.sparepartYn = data.sparepartYn; + if (data.sparepartYn) { + conditions.sparepartDescription = data.sparepartDescription; + } + } + if (fieldsToUpdate.first) { + conditions.firstYn = data.firstYn; + if (data.firstYn) { + conditions.firstDescription = data.firstDescription; + } + } + + setIsLoading(true); + + try { + const vendorIds = selectedVendors.map(v => v.id); + const result = await updateVendorConditionsBatch({ + rfqId, + vendorIds, + conditions, + }); + + if (result.success) { + toast.success(result.data?.message || "조건이 성공적으로 업데이트되었습니다."); + onSuccess(); + onOpenChange(false); + } else { + toast.error(result.error || "조건 업데이트에 실패했습니다."); + } + } catch (error) { + console.error("Submit error:", error); + toast.error("오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const getUpdateCount = () => { + return Object.values(fieldsToUpdate).filter(v => v).length; + }; + + // 선택된 옵션 찾기 헬퍼 함수들 + const selectedIncoterm = incoterms.find(i => i.code === form.watch("incotermsCode")); + const selectedPaymentTerm = paymentTerms.find(p => p.code === form.watch("paymentTermsCode")); + const selectedShipping = shippingPlaces.find(s => s.code === form.watch("placeOfShipping")); + const selectedDestination = destinationPlaces.find(d => d.code === form.watch("placeOfDestination")); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl h-[90vh] p-0 flex flex-col"> + {/* 헤더 */} + <DialogHeader className="p-6 pb-0"> + <DialogTitle>조건 일괄 설정</DialogTitle> + <DialogDescription> + 선택한 {selectedVendors.length}개 벤더에 동일한 조건을 적용합니다. + 변경하려는 항목만 체크하고 값을 입력하세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + {/* 스크롤 가능한 컨텐츠 영역 */} + <ScrollArea className="flex-1 px-6"> + <div className="grid gap-4 py-4"> + {/* 선택된 벤더 정보 */} + <Card> + <CardHeader className="pb-3"> + <div className="flex items-center justify-between"> + <CardTitle className="text-lg flex items-center gap-2"> + <Package className="h-5 w-5" /> + 대상 벤더 + </CardTitle> + <Badge>{selectedVendors.length}개</Badge> + </div> + </CardHeader> + <CardContent> + <div className="flex flex-wrap gap-2"> + {selectedVendors.map((vendor) => ( + <Badge key={vendor.id} variant="secondary"> + {vendor.vendorCode} - {vendor.vendorName} + </Badge> + ))} + </div> + </CardContent> + </Card> + + {/* 안내 메시지 */} + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + 체크박스를 선택한 항목만 업데이트됩니다. + 선택하지 않은 항목은 기존 값이 유지됩니다. + </AlertDescription> + </Alert> + + {/* 기본 조건 설정 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">기본 조건</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 통화 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.currency} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, currency: !!checked }) + } + /> + <FormField + control={form.control} + name="currency" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.currency && "text-muted-foreground" + )}> + 통화 + </FormLabel> + <div className="col-span-2"> + <FormControl> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + className="w-full justify-between" + disabled={!fieldsToUpdate.currency} + > + {field.value || "통화 선택"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="통화 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {currencies.map((currency) => ( + <CommandItem + key={currency} + value={currency} + onSelect={() => field.onChange(currency)} + > + {currency} + <Check + className={cn( + "ml-auto h-4 w-4", + currency === field.value ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + + {/* 결제 조건 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.paymentTermsCode} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, paymentTermsCode: !!checked }) + } + /> + <FormField + control={form.control} + name="paymentTermsCode" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.paymentTermsCode && "text-muted-foreground" + )}> + 결제 조건 + </FormLabel> + <div className="col-span-2"> + <Popover open={paymentTermsOpen} onOpenChange={setPaymentTermsOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={paymentTermsOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.paymentTermsCode || paymentTermsLoading} + > + {selectedPaymentTerm ? ( + <span className="truncate"> + {selectedPaymentTerm.code} - {selectedPaymentTerm.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {paymentTermsLoading ? "로딩 중..." : "결제조건 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="코드 또는 설명으로 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {paymentTerms.map((term) => ( + <CommandItem + key={term.id} + value={`${term.code} ${term.description}`} + onSelect={() => { + field.onChange(term.code); + setPaymentTermsOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{term.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{term.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + term.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + + {/* 인코텀즈 */} + <div className="flex items-start gap-4"> + <Checkbox + className="mt-3" + checked={fieldsToUpdate.incoterms} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, incoterms: !!checked }) + } + /> + <div className="flex-1 grid grid-cols-3 gap-4"> + <Label className={cn( + "text-right pt-2", + !fieldsToUpdate.incoterms && "text-muted-foreground" + )}> + 인코텀즈 + </Label> + <div className="col-span-2 space-y-2"> + <FormField + control={form.control} + name="incotermsCode" + render={({ field }) => ( + <FormItem> + <Popover open={incotermsOpen} onOpenChange={setIncotermsOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={incotermsOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.incoterms || incotermsLoading} + > + {selectedIncoterm ? ( + <span className="truncate"> + {selectedIncoterm.code} - {selectedIncoterm.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {incotermsLoading ? "로딩 중..." : "인코텀즈 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="코드 또는 설명으로 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {incoterms.map((incoterm) => ( + <CommandItem + key={incoterm.id} + value={`${incoterm.code} ${incoterm.description}`} + onSelect={() => { + field.onChange(incoterm.code); + setIncotermsOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{incoterm.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{incoterm.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + incoterm.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + {/* <FormField + control={form.control} + name="incotermsDetail" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="인코텀즈 상세 (예: 부산항)" + {...field} + disabled={!fieldsToUpdate.incoterms} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + </div> + </div> + </div> + + {/* 납기일 */} + {!rfqCode.startsWith("F") && ( + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.deliveryDate} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, deliveryDate: !!checked }) + } + /> + <FormField + control={form.control} + name="deliveryDate" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.deliveryDate && "text-muted-foreground" + )}> + 납기일 + </FormLabel> + <div className="col-span-2"> + <Popover open={calendarOpen} onOpenChange={setCalendarOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full justify-start text-left font-normal", + !field.value && "text-muted-foreground" + )} + disabled={!fieldsToUpdate.deliveryDate} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {field.value ? ( + format(field.value, "yyyy-MM-dd", { locale: ko }) + ) : ( + <span>날짜를 선택하세요</span> + )} + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0"> + <Calendar + mode="single" + selected={field.value} + onSelect={(date) => { + field.onChange(date); + setCalendarOpen(false); + }} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + )} + + {/* 계약 기간 */} + {rfqCode.startsWith("F") && ( + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.contractDuration} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, contractDuration: !!checked }) + } + /> + <FormField + control={form.control} + name="contractDuration" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.contractDuration && "text-muted-foreground" + )}> + 계약 기간 + </FormLabel> + <div className="col-span-2"> + <FormControl> + <Input + placeholder="예: 12개월" + {...field} + disabled={!fieldsToUpdate.contractDuration} + /> + </FormControl> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + )} + + {/* 세금 코드 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.taxCode} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, taxCode: !!checked }) + } + /> + <FormField + control={form.control} + name="taxCode" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.taxCode && "text-muted-foreground" + )}> + 세금 코드 + </FormLabel> + <div className="col-span-2"> + <FormControl> + <Input + {...field} + disabled={!fieldsToUpdate.taxCode} + /> + </FormControl> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + + {/* 선적지/도착지 */} + <div className="flex items-start gap-4"> + <Checkbox + className="mt-3" + checked={fieldsToUpdate.shipping} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, shipping: !!checked }) + } + /> + <div className="flex-1 space-y-2"> + <FormField + control={form.control} + name="placeOfShipping" + render={({ field }) => ( + <FormItem className="grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.shipping && "text-muted-foreground" + )}> + 선적지 + </FormLabel> + <div className="col-span-2"> + <Popover open={shippingOpen} onOpenChange={setShippingOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={shippingOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.shipping || shippingLoading} + > + {selectedShipping ? ( + <span className="truncate"> + {selectedShipping.code} - {selectedShipping.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {shippingLoading ? "로딩 중..." : "선적지 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="선적지 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {shippingPlaces.map((place) => ( + <CommandItem + key={place.id} + value={`${place.code} ${place.description}`} + onSelect={() => { + field.onChange(place.code); + setShippingOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{place.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{place.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + place.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="placeOfDestination" + render={({ field }) => ( + <FormItem className="grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.shipping && "text-muted-foreground" + )}> + 도착지 + </FormLabel> + <div className="col-span-2"> + <Popover open={destinationOpen} onOpenChange={setDestinationOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={destinationOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.shipping || destinationLoading} + > + {selectedDestination ? ( + <span className="truncate"> + {selectedDestination.code} - {selectedDestination.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {destinationLoading ? "로딩 중..." : "도착지 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="도착지 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {destinationPlaces.map((place) => ( + <CommandItem + key={place.id} + value={`${place.code} ${place.description}`} + onSelect={() => { + field.onChange(place.code); + setDestinationOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{place.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{place.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + place.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + </div> + </CardContent> + </Card> + + {/* 추가 옵션 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">추가 옵션</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 연동제 적용 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.materialPrice} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, materialPrice: !!checked }) + } + /> + <FormField + control={form.control} + name="materialPriceRelatedYn" + render={({ field }) => ( + <FormItem className="flex-1 flex items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className={cn( + !fieldsToUpdate.materialPrice && "text-muted-foreground" + )}> + 연동제 적용 + </FormLabel> + <div className="text-sm text-muted-foreground"> + 원자재 가격 연동 여부 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + disabled={!fieldsToUpdate.materialPrice} + /> + </FormControl> + </FormItem> + )} + /> + </div> + + {/* Spare Part */} + <div className="space-y-2"> + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.sparepart} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, sparepart: !!checked }) + } + /> + <FormField + control={form.control} + name="sparepartYn" + render={({ field }) => ( + <FormItem className="flex-1 flex items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className={cn( + !fieldsToUpdate.sparepart && "text-muted-foreground" + )}> + Spare Part + </FormLabel> + <div className="text-sm text-muted-foreground"> + 예비 부품 요구사항 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + disabled={!fieldsToUpdate.sparepart} + /> + </FormControl> + </FormItem> + )} + /> + </div> + {form.watch("sparepartYn") && fieldsToUpdate.sparepart && ( + <FormField + control={form.control} + name="sparepartDescription" + render={({ field }) => ( + <FormItem className="ml-7"> + <FormControl> + <Textarea + placeholder="Spare Part 요구사항을 입력하세요..." + {...field} + disabled={!fieldsToUpdate.sparepart} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 초도품 관리 */} + <div className="space-y-2"> + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.first} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, first: !!checked }) + } + /> + <FormField + control={form.control} + name="firstYn" + render={({ field }) => ( + <FormItem className="flex-1 flex items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className={cn( + !fieldsToUpdate.first && "text-muted-foreground" + )}> + 초도품 관리 + </FormLabel> + <div className="text-sm text-muted-foreground"> + 초도품 관리 요구사항 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + disabled={!fieldsToUpdate.first} + /> + </FormControl> + </FormItem> + )} + /> + </div> + {form.watch("firstYn") && fieldsToUpdate.first && ( + <FormField + control={form.control} + name="firstDescription" + render={({ field }) => ( + <FormItem className="ml-7"> + <FormControl> + <Textarea + placeholder="초도품 관리 요구사항을 입력하세요..." + {...field} + disabled={!fieldsToUpdate.first} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + </CardContent> + </Card> + </div> + </ScrollArea> + + {/* 푸터 */} + <DialogFooter className="p-6 pt-4 border-t"> + <div className="flex items-center justify-between w-full"> + <div className="text-sm text-muted-foreground"> + {getUpdateCount() > 0 + ? `${getUpdateCount()}개 항목 선택됨` + : '변경할 항목을 선택하세요' + } + </div> + <div className="flex gap-2"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading || getUpdateCount() === 0} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {getUpdateCount() > 0 + ? `${getUpdateCount()}개 항목 업데이트` + : '조건 업데이트' + } + </Button> + </div> + </div> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx new file mode 100644 index 00000000..b6d42804 --- /dev/null +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -0,0 +1,746 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Plus, + Send, + Eye, + Edit, + Trash2, + Building2, + Calendar, + DollarSign, + FileText, + RefreshCw, + Mail, + CheckCircle, + Clock, + XCircle, + AlertCircle, + Settings2, + ClipboardList, + Globe, + Package, + MapPin, + Info +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; +import { ClientDataTable } from "@/components/client-data-table/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { DataTableAdvancedFilterField } from "@/types/table"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { AddVendorDialog } from "./add-vendor-dialog"; +import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; +// import { VendorDetailDialog } from "./vendor-detail-dialog"; + +// 타입 정의 +interface RfqDetail { + detailId: number; + vendorId: number | null; + vendorName: string | null; + vendorCode: string | null; + vendorCountry: string | null; + vendorCategory?: string | null; // 업체분류 + vendorGrade?: string | null; // AVL 등급 + basicContract?: string | null; // 기본계약 + shortList: boolean; + currency: string | null; + paymentTermsCode: string | null; + paymentTermsDescription: string | null; + incotermsCode: string | null; + incotermsDescription: string | null; + incotermsDetail?: string | null; + deliveryDate: Date | null; + contractDuration: string | null; + taxCode: string | null; + placeOfShipping?: string | null; + placeOfDestination?: string | null; + materialPriceRelatedYn?: boolean | null; + sparepartYn?: boolean | null; + firstYn?: boolean | null; + firstDescription?: string | null; + sparepartDescription?: string | null; + updatedAt?: Date | null; + updatedByUserName?: string | null; +} + +interface VendorResponse { + id: number; + vendorId: number; + status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + responseVersion: number; + isLatest: boolean; + submittedAt: Date | null; + totalAmount: number | null; + currency: string | null; + vendorDeliveryDate: Date | null; + quotedItemCount?: number; + attachmentCount?: number; +} + +interface RfqVendorTableProps { + rfqId: number; + rfqCode?: string; + rfqDetails: RfqDetail[]; + vendorResponses: VendorResponse[]; +} + +// 상태별 아이콘 반환 +const getStatusIcon = (status: string) => { + switch (status) { + case "초대됨": return <Mail className="h-4 w-4" />; + case "작성중": return <Clock className="h-4 w-4" />; + case "제출완료": return <CheckCircle className="h-4 w-4" />; + case "수정요청": return <AlertCircle className="h-4 w-4" />; + case "최종확정": return <FileText className="h-4 w-4" />; + case "취소": return <XCircle className="h-4 w-4" />; + default: return <Clock className="h-4 w-4" />; + } +}; + +// 상태별 색상 +const getStatusVariant = (status: string) => { + switch (status) { + case "초대됨": return "secondary"; + case "작성중": return "outline"; + case "제출완료": return "default"; + case "수정요청": return "warning"; + case "최종확정": return "success"; + case "취소": return "destructive"; + default: return "outline"; + } +}; + +// 데이터 병합 (rfqDetails + vendorResponses) +const mergeVendorData = ( + rfqDetails: RfqDetail[], + vendorResponses: VendorResponse[], + rfqCode?: string +): (RfqDetail & { response?: VendorResponse; rfqCode?: string })[] => { + return rfqDetails.map(detail => { + const response = vendorResponses.find( + r => r.vendorId === detail.vendorId && r.isLatest + ); + return { ...detail, response, rfqCode }; + }); +}; + +// 추가 조건 포맷팅 +const formatAdditionalConditions = (data: any) => { + const conditions = []; + if (data.firstYn) conditions.push("초도품"); + if (data.materialPriceRelatedYn) conditions.push("연동제"); + if (data.sparepartYn) conditions.push("스페어"); + return conditions.length > 0 ? conditions.join(", ") : "-"; +}; + +export function RfqVendorTable({ + rfqId, + rfqCode, + rfqDetails, + vendorResponses, +}: RfqVendorTableProps) { + const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState<any[]>([]); + const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false); + const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false); + const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null); + + // 데이터 병합 + const mergedData = React.useMemo( + () => mergeVendorData(rfqDetails, vendorResponses, rfqCode), + [rfqDetails, vendorResponses, rfqCode] + ); + + // 액션 처리 + const handleAction = React.useCallback(async (action: string, vendor: any) => { + switch (action) { + case "view": + setSelectedVendor(vendor); + break; + + case "send": + // RFQ 발송 로직 + toast.info(`${vendor.vendorName}에게 RFQ를 발송합니다.`); + break; + + case "edit": + // 수정 로직 + toast.info("수정 기능은 준비중입니다."); + break; + + case "delete": + // 삭제 로직 + if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) { + toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`); + } + break; + + case "response-detail": + // 회신 상세 보기 + toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`); + break; + } + }, []); + + // 선택된 벤더들에게 일괄 발송 + const handleBulkSend = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("발송할 벤더를 선택해주세요."); + return; + } + + const vendorNames = selectedRows.map(r => r.vendorName).join(", "); + if (confirm(`선택한 ${selectedRows.length}개 벤더에게 RFQ를 발송하시겠습니까?\n\n${vendorNames}`)) { + toast.success(`${selectedRows.length}개 벤더에게 RFQ를 발송했습니다.`); + setSelectedRows([]); + } + }, [selectedRows]); + + + // 컬럼 정의 (확장된 버전) + const columns: ColumnDef<any>[] = React.useMemo(() => [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "rfqCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="ITB/RFQ/견적 No." />, + cell: ({ row }) => { + return ( + <span className="font-mono text-xs">{row.original.rfqCode || "-"}</span> + ); + }, + size: 120, + }, + // { + // accessorKey: "response.responseVersion", + // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Rev" />, + // cell: ({ row }) => { + // const version = row.original.response?.responseVersion; + // return version ? ( + // <Badge variant="outline" className="font-mono">v{version}</Badge> + // ) : ( + // <span className="text-muted-foreground">-</span> + // ); + // }, + // size: 60, + // }, + { + accessorKey: "vendorName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="협력업체정보" />, + cell: ({ row }) => { + const vendor = row.original; + return ( + <div className="flex items-center gap-2"> + <Building2 className="h-4 w-4 text-muted-foreground" /> + <div className="flex flex-col"> + <span className="text-sm font-medium">{vendor.vendorName || "-"}</span> + <span className="text-xs text-muted-foreground">{vendor.vendorCode}</span> + </div> + </div> + ); + }, + size: 180, + }, + { + accessorKey: "vendorCategory", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업체분류" />, + cell: ({ row }) => row.original.vendorCategory || "-", + size: 100, + }, + { + accessorKey: "vendorCountry", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="내외자 (위치)" />, + cell: ({ row }) => { + const country = row.original.vendorCountry; + const isLocal = country === "KR" || country === "한국"; + return ( + <Badge variant={isLocal ? "default" : "secondary"}> + {country || "-"} + </Badge> + ); + }, + size: 100, + }, + { + accessorKey: "vendorGrade", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="AVL 정보 (등급)" />, + cell: ({ row }) => { + const grade = row.original.vendorGrade; + if (!grade) return <span className="text-muted-foreground">-</span>; + + const gradeColor = { + "A": "text-green-600", + "B": "text-blue-600", + "C": "text-yellow-600", + "D": "text-red-600", + }[grade] || "text-gray-600"; + + return <span className={cn("font-semibold", gradeColor)}>{grade}</span>; + }, + size: 100, + }, + { + accessorKey: "basicContract", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />, + cell: ({ row }) => row.original.basicContract || "-", + size: 100, + }, + { + accessorKey: "currency", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="요청 통화" />, + cell: ({ row }) => { + const currency = row.original.currency; + return currency ? ( + <Badge variant="outline">{currency}</Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 80, + }, + { + accessorKey: "paymentTermsCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="지급조건" />, + cell: ({ row }) => { + const code = row.original.paymentTermsCode; + const desc = row.original.paymentTermsDescription; + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-sm">{code || "-"}</span> + </TooltipTrigger> + {desc && ( + <TooltipContent> + <p>{desc}</p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + ); + }, + size: 100, + }, + { + accessorKey: "taxCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Tax" />, + cell: ({ row }) => row.original.taxCode || "-", + size: 60, + }, + { + accessorKey: "deliveryDate", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="계약납기일/기간" />, + cell: ({ row }) => { + const deliveryDate = row.original.deliveryDate; + const contractDuration = row.original.contractDuration; + + return ( + <div className="flex flex-col gap-0.5"> + {deliveryDate && ( + <span className="text-xs"> + {format(new Date(deliveryDate), "yyyy-MM-dd")} + </span> + )} + {contractDuration && ( + <span className="text-xs text-muted-foreground">{contractDuration}</span> + )} + {!deliveryDate && !contractDuration && ( + <span className="text-muted-foreground">-</span> + )} + </div> + ); + }, + size: 120, + }, + { + accessorKey: "incotermsCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Incoterms" />, + cell: ({ row }) => { + const code = row.original.incotermsCode; + const detail = row.original.incotermsDetail; + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-1"> + <Globe className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm">{code || "-"}</span> + </div> + </TooltipTrigger> + {detail && ( + <TooltipContent> + <p>{detail}</p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + ); + }, + size: 100, + }, + { + accessorKey: "placeOfShipping", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="선적지" />, + cell: ({ row }) => { + const place = row.original.placeOfShipping; + return place ? ( + <div className="flex items-center gap-1"> + <MapPin className="h-3 w-3 text-muted-foreground" /> + <span className="text-xs">{place}</span> + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "placeOfDestination", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="도착지" />, + cell: ({ row }) => { + const place = row.original.placeOfDestination; + return place ? ( + <div className="flex items-center gap-1"> + <Package className="h-3 w-3 text-muted-foreground" /> + <span className="text-xs">{place}</span> + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + id: "additionalConditions", + header: "추가조건", + cell: ({ row }) => { + const conditions = formatAdditionalConditions(row.original); + if (conditions === "-") { + return <span className="text-muted-foreground">-</span>; + } + + const items = conditions.split(", "); + return ( + <div className="flex flex-wrap gap-1"> + {items.map((item, idx) => ( + <Badge key={idx} variant="outline" className="text-xs"> + {item} + </Badge> + ))} + </div> + ); + }, + size: 120, + }, + { + accessorKey: "response.submittedAt", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />, + cell: ({ row }) => { + const submittedAt = row.original.response?.submittedAt; + const status = row.original.response?.status; + + if (!submittedAt) { + return <Badge variant="outline">미참여</Badge>; + } + + return ( + <div className="flex flex-col gap-0.5"> + <Badge variant="default" className="text-xs">참여</Badge> + <span className="text-xs text-muted-foreground"> + {format(new Date(submittedAt), "MM-dd")} + </span> + </div> + ); + }, + size: 100, + }, + { + id: "responseDetail", + header: "회신상세", + cell: ({ row }) => { + const hasResponse = !!row.original.response?.submittedAt; + + if (!hasResponse) { + return <span className="text-muted-foreground text-xs">-</span>; + } + + return ( + <Button + variant="ghost" + size="sm" + onClick={() => handleAction("response-detail", row.original)} + className="h-7 px-2" + > + <Eye className="h-3 w-3 mr-1" /> + 상세 + </Button> + ); + }, + size: 80, + }, + { + accessorKey: "shortList", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Short List" />, + cell: ({ row }) => ( + row.original.shortList ? ( + <Badge variant="default">선정</Badge> + ) : ( + <Badge variant="outline">대기</Badge> + ) + ), + size: 80, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정일" />, + cell: ({ row }) => { + const date = row.original.updatedAt; + return date ? ( + <span className="text-xs text-muted-foreground"> + {format(new Date(date), "MM-dd HH:mm")} + </span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "updatedByUserName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정자" />, + cell: ({ row }) => { + const name = row.original.updatedByUserName; + return name ? ( + <span className="text-xs">{name}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + const vendor = row.original; + const hasResponse = !!vendor.response; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M3.625 7.5C3.625 8.12132 3.12132 8.625 2.5 8.625C1.87868 8.625 1.375 8.12132 1.375 7.5C1.375 6.87868 1.87868 6.375 2.5 6.375C3.12132 6.375 3.625 6.87868 3.625 7.5ZM8.625 7.5C8.625 8.12132 8.12132 8.625 7.5 8.625C6.87868 8.625 6.375 8.12132 6.375 7.5C6.375 6.87868 6.87868 6.375 7.5 6.375C8.12132 6.375 8.625 6.87868 8.625 7.5ZM12.5 8.625C13.1213 8.625 13.625 8.12132 13.625 7.5C13.625 6.87868 13.1213 6.375 12.5 6.375C11.8787 6.375 11.375 6.87868 11.375 7.5C11.375 8.12132 11.8787 8.625 12.5 8.625Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path> + </svg> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => handleAction("view", vendor)}> + <Eye className="mr-2 h-4 w-4" /> + 상세보기 + </DropdownMenuItem> + {!hasResponse && ( + <DropdownMenuItem onClick={() => handleAction("send", vendor)}> + <Send className="mr-2 h-4 w-4" /> + RFQ 발송 + </DropdownMenuItem> + )} + <DropdownMenuItem onClick={() => handleAction("edit", vendor)}> + <Edit className="mr-2 h-4 w-4" /> + 조건 수정 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => handleAction("delete", vendor)} + className="text-red-600" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + size: 60, + }, + ], [handleAction]); + + const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ + { id: "vendorName", label: "벤더명", type: "text" }, + { id: "vendorCode", label: "벤더코드", type: "text" }, + { id: "vendorCountry", label: "국가", type: "text" }, + { + id: "response.status", + label: "응답 상태", + type: "select", + options: [ + { label: "초대됨", value: "초대됨" }, + { label: "작성중", value: "작성중" }, + { label: "제출완료", value: "제출완료" }, + { label: "수정요청", value: "수정요청" }, + { label: "최종확정", value: "최종확정" }, + { label: "취소", value: "취소" }, + ] + }, + { + id: "shortList", + label: "Short List", + type: "select", + options: [ + { label: "선정", value: "true" }, + { label: "대기", value: "false" }, + ] + }, + ]; + + // 선택된 벤더 정보 (BatchUpdate용) + const selectedVendorsForBatch = React.useMemo(() => { + return selectedRows.map(row => ({ + id: row.vendorId, + vendorName: row.vendorName, + vendorCode: row.vendorCode, + })); + }, [selectedRows]); + + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => setIsAddDialogOpen(true)} + > + <Plus className="h-4 w-4 mr-2" /> + 벤더 추가 + </Button> + {selectedRows.length > 0 && ( + <> + <Button + variant="outline" + size="sm" + onClick={() => setIsBatchUpdateOpen(true)} + > + <Settings2 className="h-4 w-4 mr-2" /> + 정보 일괄 입력 ({selectedRows.length}) + </Button> + <Button + variant="outline" + size="sm" + onClick={handleBulkSend} + > + <Send className="h-4 w-4 mr-2" /> + 선택 발송 ({selectedRows.length}) + </Button> + </> + )} + <Button + variant="outline" + size="sm" + onClick={() => { + setIsRefreshing(true); + setTimeout(() => { + setIsRefreshing(false); + toast.success("데이터를 새로고침했습니다."); + }, 1000); + }} + disabled={isRefreshing} + > + <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> + 새로고침 + </Button> + </div> + ), [selectedRows, isRefreshing, handleBulkSend]); + + return ( + <> + <ClientDataTable + columns={columns} + data={mergedData} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={false} + compact={true} + maxHeight="34rem" + onSelectedRowsChange={setSelectedRows} + > + {additionalActions} + </ClientDataTable> + + {/* 벤더 추가 다이얼로그 */} + <AddVendorDialog + open={isAddDialogOpen} + onOpenChange={setIsAddDialogOpen} + rfqId={rfqId} + onSuccess={() => { + toast.success("벤더가 추가되었습니다."); + setIsAddDialogOpen(false); + }} + /> + + {/* 조건 일괄 설정 다이얼로그 */} + <BatchUpdateConditionsDialog + open={isBatchUpdateOpen} + onOpenChange={setIsBatchUpdateOpen} + rfqId={rfqId} + rfqCode={rfqCode} + selectedVendors={selectedVendorsForBatch} + onSuccess={() => { + toast.success("조건이 업데이트되었습니다."); + setIsBatchUpdateOpen(false); + setSelectedRows([]); + }} + /> + + {/* 벤더 상세 다이얼로그 */} + {/* {selectedVendor && ( + <VendorDetailDialog + open={!!selectedVendor} + onOpenChange={(open) => !open && setSelectedVendor(null)} + vendor={selectedVendor} + rfqId={rfqId} + /> + )} */} + </> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx diff --git a/lib/rfq-last/vendor/vendor-response-status-card.tsx b/lib/rfq-last/vendor/vendor-response-status-card.tsx new file mode 100644 index 00000000..d4ef8dd3 --- /dev/null +++ b/lib/rfq-last/vendor/vendor-response-status-card.tsx @@ -0,0 +1,51 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface VendorResponseStatusCardProps { + title: string; + count: number; + icon: LucideIcon; + variant?: "default" | "primary" | "secondary" | "success" | "warning" | "destructive"; +} + +const variantStyles = { + default: "border-gray-200 bg-gray-50/50", + primary: "border-blue-200 bg-blue-50/50", + secondary: "border-purple-200 bg-purple-50/50", + success: "border-green-200 bg-green-50/50", + warning: "border-yellow-200 bg-yellow-50/50", + destructive: "border-red-200 bg-red-50/50", +}; + +const iconStyles = { + default: "text-gray-600", + primary: "text-blue-600", + secondary: "text-purple-600", + success: "text-green-600", + warning: "text-yellow-600", + destructive: "text-red-600", +}; + +export function VendorResponseStatusCard({ + title, + count, + icon: Icon, + variant = "default", +}: VendorResponseStatusCardProps) { + return ( + <Card className={cn("border", variantStyles[variant])}> + <CardContent className="p-4"> + <div className="flex items-center justify-between"> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground">{title}</p> + <p className="text-2xl font-bold">{count}</p> + </div> + <div className={cn("p-2 rounded-full bg-white/80", iconStyles[variant])}> + <Icon className="h-5 w-5" /> + </div> + </div> + </CardContent> + </Card> + ); +}
\ No newline at end of file |
