diff options
Diffstat (limited to 'lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx')
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx | 713 |
1 files changed, 713 insertions, 0 deletions
diff --git a/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx new file mode 100644 index 00000000..143d08f3 --- /dev/null +++ b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx @@ -0,0 +1,713 @@ +"use client" + +import * as React from "react" +import { useFormContext } from "react-hook-form" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { InfoIcon, ChevronsUpDown, Check, CalendarIcon, Loader2 } from "lucide-react" +import { format } from "date-fns" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + getIncotermsForSelection, + getPaymentTermsForSelection, + getPlaceOfShippingForSelection, + getPlaceOfDestinationForSelection +} from "@/lib/procurement-select/service" +import { toast } from "sonner" + +interface CommercialTermsFormProps { + rfqDetail: any + rfq: any +} + +interface SelectOption { + id: number + code: string + description: string +} + +export default function CommercialTermsForm({ rfqDetail, rfq }: CommercialTermsFormProps) { + const { register, setValue, watch, formState: { errors } } = useFormContext() + + console.log(rfqDetail,"rfqDetail") + + // RFQ 코드가 F로 시작하는지 확인 + const isFrameContract = rfq?.rfqCode?.startsWith("F") + + // 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 vendorCurrency = watch("vendorCurrency") + const vendorPaymentTermsCode = watch("vendorPaymentTermsCode") + const vendorIncotermsCode = watch("vendorIncotermsCode") + const vendorDeliveryDate = watch("vendorDeliveryDate") + const vendorContractDuration = watch("vendorContractDuration") + const vendorFirstYn = watch("vendorFirstYn") + const vendorSparepartYn = watch("vendorSparepartYn") + const vendorMaterialPriceRelatedYn = watch("vendorMaterialPriceRelatedYn") + + // 구매자 제시 조건과 다른지 확인 + const isDifferentCurrency = vendorCurrency !== rfqDetail.currency + const isDifferentPaymentTerms = vendorPaymentTermsCode !== rfqDetail.paymentTermsCode + const isDifferentIncoterms = vendorIncotermsCode !== rfqDetail.incotermsCode + const isDifferentDeliveryDate = !isFrameContract && vendorDeliveryDate?.toISOString() !== rfqDetail.deliveryDate + const isDifferentContractDuration = isFrameContract && vendorContractDuration !== rfqDetail.contractDuration + + // 데이터 로드 함수들 + 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(() => { + loadIncoterms() + loadPaymentTerms() + loadShippingPlaces() + loadDestinationPlaces() + }, [loadIncoterms, loadPaymentTerms, loadShippingPlaces, loadDestinationPlaces]) + + // 선택된 옵션 찾기 + const selectedIncoterm = incoterms.find(i => i.code === vendorIncotermsCode) + const selectedPaymentTerm = paymentTerms.find(p => p.code === vendorPaymentTermsCode) + const selectedShipping = shippingPlaces.find(s => s.code === watch("vendorPlaceOfShipping")) + const selectedDestination = destinationPlaces.find(d => d.code === watch("vendorPlaceOfDestination")) + + return ( + <div className="space-y-6"> + {/* 기본 상업 조건 */} + <Card> + <CardHeader> + <CardTitle>기본 상업 조건</CardTitle> + <CardDescription> + 구매자가 제시한 조건과 다른 경우, 변경 사유를 반드시 입력해주세요. + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + {/* 통화 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label>구매자 제시 통화</Label> + <Input value={rfqDetail.currency || '-'} disabled /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorCurrency">벤더 제안 통화</Label> + <Select + value={vendorCurrency} + onValueChange={(value) => setValue("vendorCurrency", value)} + > + <SelectTrigger> + <SelectValue placeholder="통화 선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="USD">USD</SelectItem> + <SelectItem value="KRW">KRW</SelectItem> + <SelectItem value="EUR">EUR</SelectItem> + <SelectItem value="JPY">JPY</SelectItem> + <SelectItem value="CNY">CNY</SelectItem> + </SelectContent> + </Select> + </div> + </div> + {isDifferentCurrency && ( + <div className="space-y-2"> + <Label htmlFor="currencyReason">통화 변경 사유 *</Label> + <Textarea + id="currencyReason" + {...register("currencyReason")} + placeholder="통화 변경 사유를 입력하세요" + className="min-h-[80px]" + /> + </div> + )} + + {/* 지불 조건 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label>구매자 제시 지불조건</Label> + <Input value={rfqDetail.paymentTermsDescription || rfqDetail.paymentTermsCode || '-'} disabled /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorPaymentTermsCode">벤더 제안 지불조건</Label> + <Popover open={paymentTermsOpen} onOpenChange={setPaymentTermsOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={paymentTermsOpen} + className="w-full justify-between" + disabled={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> + </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={() => { + setValue("vendorPaymentTermsCode", 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 === vendorPaymentTermsCode ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + </div> + {isDifferentPaymentTerms && ( + <div className="space-y-2"> + <Label htmlFor="paymentTermsReason">지불조건 변경 사유 *</Label> + <Textarea + id="paymentTermsReason" + {...register("paymentTermsReason")} + placeholder="지불조건 변경 사유를 입력하세요" + className="min-h-[80px]" + /> + </div> + )} + + {/* 인코텀즈 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label>구매자 제시 인코텀즈</Label> + <Input value={`${rfqDetail.incotermsCode || ''} ${rfqDetail.incotermsDetail || ''}`.trim() || '-'} disabled /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorIncotermsCode">벤더 제안 인코텀즈</Label> + <Popover open={incotermsOpen} onOpenChange={setIncotermsOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={incotermsOpen} + className="w-full justify-between" + disabled={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> + </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={() => { + setValue("vendorIncotermsCode", 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 === vendorIncotermsCode ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + </div> + {isDifferentIncoterms && ( + <div className="space-y-2"> + <Label htmlFor="incotermsReason">인코텀즈 변경 사유 *</Label> + <Textarea + id="incotermsReason" + {...register("incotermsReason")} + placeholder="인코텀즈 변경 사유를 입력하세요" + className="min-h-[80px]" + /> + </div> + )} + + {/* 납기일 또는 계약 기간 */} + {isFrameContract ? ( + // 계약 기간 (F로 시작하는 경우) + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label>구매자 제시 계약기간</Label> + <Input value={rfqDetail.contractDuration || '-'} disabled /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorContractDuration">벤더 제안 계약기간</Label> + <Input + id="vendorContractDuration" + {...register("vendorContractDuration")} + placeholder="예: 12개월" + /> + </div> + </div> + ) : ( + // 납기일 (일반적인 경우) + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label>구매자 제시 납기일</Label> + <Input + value={rfqDetail.deliveryDate ? format(new Date(rfqDetail.deliveryDate), "yyyy-MM-dd") : '-'} + disabled + /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorDeliveryDate">벤더 제안 납기일</Label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + className={cn( + "w-full justify-start text-left font-normal", + !vendorDeliveryDate && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {vendorDeliveryDate ? format(vendorDeliveryDate, "yyyy-MM-dd") : "날짜 선택"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0"> + <Calendar + mode="single" + selected={vendorDeliveryDate} + onSelect={(date) => setValue("vendorDeliveryDate", date)} + initialFocus + /> + </PopoverContent> + </Popover> + </div> + </div> + )} + + {/* 납기일/계약기간 변경 사유 (공통) */} + {(isDifferentDeliveryDate || isDifferentContractDuration) && ( + <div className="space-y-2"> + <Label htmlFor="deliveryDateReason"> + {isFrameContract ? "계약기간 변경 사유 *" : "납기일 변경 사유 *"} + </Label> + <Textarea + id="deliveryDateReason" + {...register("deliveryDateReason")} + placeholder={isFrameContract ? "계약기간 변경 사유를 입력하세요" : "납기일 변경 사유를 입력하세요"} + className="min-h-[80px]" + /> + </div> + )} + + {/* 기타 조건들 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="vendorTaxCode">세금 코드</Label> + <Input + id="vendorTaxCode" + {...register("vendorTaxCode")} + placeholder="세금 코드 입력" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorPlaceOfShipping">선적지</Label> + <Popover open={shippingOpen} onOpenChange={setShippingOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={shippingOpen} + className="w-full justify-between" + disabled={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> + </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={() => { + setValue("vendorPlaceOfShipping", 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 === watch("vendorPlaceOfShipping") ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorPlaceOfDestination">도착지</Label> + <Popover open={destinationOpen} onOpenChange={setDestinationOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={destinationOpen} + className="w-full justify-between" + disabled={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> + </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={() => { + setValue("vendorPlaceOfDestination", 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 === watch("vendorPlaceOfDestination") ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + </div> + </CardContent> + </Card> + + {/* 특수 조건 */} + <Card> + <CardHeader> + <CardTitle>특수 조건</CardTitle> + <CardDescription> + 구매자가 요청한 특수 조건에 대한 응답을 입력하세요. + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + {/* 초도품 관리 */} + {rfqDetail.firstYn && ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <Label>초도품 관리 요청</Label> + <Badge variant="secondary">요청됨</Badge> + </div> + {rfqDetail.firstDescription && ( + <Alert> + <InfoIcon className="h-4 w-4" /> + <AlertDescription>{rfqDetail.firstDescription}</AlertDescription> + </Alert> + )} + <div className="space-y-2"> + <Label>초도품 관리 수용 여부</Label> + <RadioGroup + value={watch("vendorFirstAcceptance") || ""} + onValueChange={(value) => setValue("vendorFirstAcceptance", value)} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="수용" id="first-accept" /> + <Label htmlFor="first-accept">수용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="부분수용" id="first-partial" /> + <Label htmlFor="first-partial">부분수용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="거부" id="first-reject" /> + <Label htmlFor="first-reject">거부</Label> + </div> + </RadioGroup> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorFirstDescription">초도품 관리 응답 상세</Label> + <Textarea + id="vendorFirstDescription" + {...register("vendorFirstDescription")} + placeholder="초도품 관리에 대한 상세 응답을 입력하세요" + className="min-h-[100px]" + /> + </div> + </div> + )} + + {/* Spare Part */} + {rfqDetail.sparepartYn && ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <Label>Spare Part 요청</Label> + <Badge variant="secondary">요청됨</Badge> + </div> + {rfqDetail.sparepartDescription && ( + <Alert> + <InfoIcon className="h-4 w-4" /> + <AlertDescription>{rfqDetail.sparepartDescription}</AlertDescription> + </Alert> + )} + <div className="space-y-2"> + <Label>Spare Part 수용 여부</Label> + <RadioGroup + value={watch("vendorSparepartAcceptance") || ""} + onValueChange={(value) => setValue("vendorSparepartAcceptance", value)} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="수용" id="spare-accept" /> + <Label htmlFor="spare-accept">수용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="부분수용" id="spare-partial" /> + <Label htmlFor="spare-partial">부분수용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="거부" id="spare-reject" /> + <Label htmlFor="spare-reject">거부</Label> + </div> + </RadioGroup> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorSparepartDescription">Spare Part 응답 상세</Label> + <Textarea + id="vendorSparepartDescription" + {...register("vendorSparepartDescription")} + placeholder="Spare Part에 대한 상세 응답을 입력하세요" + className="min-h-[100px]" + /> + </div> + </div> + )} + + {/* 연동제 적용 */} + {rfqDetail.materialPriceRelatedYn && ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <Label>연동제 적용 요청</Label> + <Badge variant="secondary">요청됨</Badge> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="vendorMaterialPriceRelatedYn" + checked={vendorMaterialPriceRelatedYn} + onCheckedChange={(checked) => setValue("vendorMaterialPriceRelatedYn", checked)} + /> + <Label htmlFor="vendorMaterialPriceRelatedYn">연동제 적용 동의</Label> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorMaterialPriceRelatedReason">연동제 관련 의견</Label> + <Textarea + id="vendorMaterialPriceRelatedReason" + {...register("vendorMaterialPriceRelatedReason")} + placeholder="연동제 적용에 대한 의견을 입력하세요" + className="min-h-[100px]" + /> + </div> + </div> + )} + </CardContent> + </Card> + + {/* 추가 의견 */} + <Card> + <CardHeader> + <CardTitle>추가 의견</CardTitle> + <CardDescription> + 견적서에 대한 추가 의견을 입력하세요. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* <div className="space-y-2"> + <Label htmlFor="technicalProposal">기술 제안서</Label> + <Textarea + id="technicalProposal" + {...register("technicalProposal")} + placeholder="기술적 제안사항을 입력하세요" + className="min-h-[120px]" + /> + </div> */} + <div className="space-y-2"> + {/* <Label htmlFor="generalRemark">일반 비고</Label> */} + <Textarea + id="generalRemark" + {...register("generalRemark")} + placeholder="추가 비고사항을 입력하세요" + className="min-h-[120px]" + /> + </div> + </CardContent> + </Card> + </div> + ) +}
\ No newline at end of file |
