diff options
Diffstat (limited to 'lib/rfq-last/vendor-response/editor')
5 files changed, 2318 insertions, 0 deletions
diff --git a/lib/rfq-last/vendor-response/editor/attachments-upload.tsx b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx new file mode 100644 index 00000000..a2967767 --- /dev/null +++ b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx @@ -0,0 +1,466 @@ +"use client" + +import { useState, useRef } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Upload, + FileText, + File, + Trash2, + Download, + AlertCircle, + Paperclip, + FileCheck, + Calculator, + Wrench +} from "lucide-react" +import { formatBytes } from "@/lib/utils" +import { cn } from "@/lib/utils" + +interface FileWithType extends File { + attachmentType?: "구매" | "설계" + description?: string +} + +interface AttachmentsUploadProps { + attachments: FileWithType[] + onAttachmentsChange: (files: FileWithType[]) => void + existingAttachments?: any[] +} + +const acceptedFileTypes = { + documents: ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx", + images: ".jpg,.jpeg,.png,.gif,.bmp", + compressed: ".zip,.rar,.7z", + all: ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.bmp,.zip,.rar,.7z" +} + +export default function AttachmentsUpload({ + attachments, + onAttachmentsChange, + existingAttachments = [] +}: AttachmentsUploadProps) { + const purchaseInputRef = useRef<HTMLInputElement>(null) + const designInputRef = useRef<HTMLInputElement>(null) + const [purchaseDragActive, setPurchaseDragActive] = useState(false) + const [designDragActive, setDesignDragActive] = useState(false) + const [uploadErrors, setUploadErrors] = useState<string[]>([]) + + // 파일 유효성 검사 + const validateFile = (file: File): string | null => { + const maxSize = 1024 * 1024 * 1024 // 10MB + const allowedExtensions = acceptedFileTypes.all.split(',') + const fileExtension = `.${file.name.split('.').pop()?.toLowerCase()}` + + if (file.size > maxSize) { + return `${file.name}: 파일 크기가 1GB를 초과합니다` + } + + if (!allowedExtensions.includes(fileExtension)) { + return `${file.name}: 허용되지 않은 파일 형식입니다` + } + + return null + } + + // 파일 추가 + const handleFileAdd = (files: FileList | null, type: "구매" | "설계") => { + if (!files) return + + const newFiles: FileWithType[] = [] + const errors: string[] = [] + + Array.from(files).forEach(file => { + const error = validateFile(file) + if (error) { + errors.push(error) + } else { + const fileWithType = Object.assign(file, { + attachmentType: type, + description: "" + }) + newFiles.push(fileWithType) + } + }) + + if (errors.length > 0) { + setUploadErrors(errors) + setTimeout(() => setUploadErrors([]), 5000) + } + + if (newFiles.length > 0) { + onAttachmentsChange([...attachments, ...newFiles]) + } + } + + // 구매 드래그 앤 드롭 핸들러 + const handlePurchaseDrag = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.type === "dragenter" || e.type === "dragover") { + setPurchaseDragActive(true) + } else if (e.type === "dragleave") { + setPurchaseDragActive(false) + } + } + + const handlePurchaseDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setPurchaseDragActive(false) + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFileAdd(e.dataTransfer.files, "구매") + } + } + + // 설계 드래그 앤 드롭 핸들러 + const handleDesignDrag = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.type === "dragenter" || e.type === "dragover") { + setDesignDragActive(true) + } else if (e.type === "dragleave") { + setDesignDragActive(false) + } + } + + const handleDesignDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDesignDragActive(false) + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFileAdd(e.dataTransfer.files, "설계") + } + } + + // 파일 삭제 + const handleFileRemove = (index: number) => { + const newFiles = attachments.filter((_, i) => i !== index) + onAttachmentsChange(newFiles) + } + + // 파일 타입 변경 + const handleTypeChange = (index: number, newType: "구매" | "설계") => { + const newFiles = [...attachments] + newFiles[index].attachmentType = newType + onAttachmentsChange(newFiles) + } + + // 파일 아이콘 가져오기 + const getFileIcon = (fileName: string) => { + const extension = fileName.split('.').pop()?.toLowerCase() + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp'] + + if (imageExtensions.includes(extension || '')) { + return <File className="h-4 w-4 text-blue-500" /> + } + return <FileText className="h-4 w-4 text-gray-500" /> + } + + // 구매/설계 문서 개수 계산 + const purchaseCount = attachments.filter(f => f.attachmentType === "구매").length + + existingAttachments.filter(f => f.attachmentType === "구매").length + const designCount = attachments.filter(f => f.attachmentType === "설계").length + + existingAttachments.filter(f => f.attachmentType === "설계").length + + return ( + <div className="space-y-4"> + {/* 필수 파일 안내 */} + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>문서 분류:</strong> 구매 문서(견적서, 상업조건 등)와 설계 문서(기술문서, 성적서, 인증서 등)를 구분하여 업로드하세요. + <br /> + <strong>허용 파일:</strong> PDF, Word, Excel, PowerPoint, 이미지 파일, 압축 파일(ZIP, RAR, 7Z) (최대 1GB) + </AlertDescription> + </Alert> + + {/* 업로드 오류 표시 */} + {uploadErrors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <ul className="list-disc list-inside"> + {uploadErrors.map((error, index) => ( + <li key={index}>{error}</li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + + {/* 두 개의 드래그존 */} + <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> + {/* 구매 문서 업로드 영역 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Calculator className="h-5 w-5" /> + 구매 문서 + </CardTitle> + <CardDescription> + 견적서, 금액, 상업조건 관련 문서 + </CardDescription> + </CardHeader> + <CardContent> + <div + className={cn( + "border-2 border-dashed rounded-lg p-6 text-center transition-colors", + purchaseDragActive ? "border-blue-500 bg-blue-50" : "border-gray-300", + "hover:border-blue-400 hover:bg-blue-50/50" + )} + onDragEnter={handlePurchaseDrag} + onDragLeave={handlePurchaseDrag} + onDragOver={handlePurchaseDrag} + onDrop={handlePurchaseDrop} + > + <Calculator className="mx-auto h-10 w-10 text-blue-500 mb-3" /> + <p className="text-sm text-gray-600 mb-2"> + 구매 문서를 드래그하여 업로드 + </p> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => purchaseInputRef.current?.click()} + className="border-blue-500 text-blue-600 hover:bg-blue-50" + > + <Paperclip className="h-4 w-4 mr-2" /> + 구매 문서 선택 + </Button> + <input + ref={purchaseInputRef} + type="file" + multiple + accept={acceptedFileTypes.all} + onChange={(e) => handleFileAdd(e.target.files, "구매")} + className="hidden" + /> + {purchaseCount > 0 && ( + <div className="mt-2"> + <Badge variant="secondary">{purchaseCount}개 업로드됨</Badge> + </div> + )} + </div> + </CardContent> + </Card> + + {/* 설계 문서 업로드 영역 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Wrench className="h-5 w-5" /> + 설계 문서 + </CardTitle> + <CardDescription> + 기술문서, 성적서, 인증서, 도면 등 + </CardDescription> + </CardHeader> + <CardContent> + <div + className={cn( + "border-2 border-dashed rounded-lg p-6 text-center transition-colors", + designDragActive ? "border-green-500 bg-green-50" : "border-gray-300", + "hover:border-green-400 hover:bg-green-50/50" + )} + onDragEnter={handleDesignDrag} + onDragLeave={handleDesignDrag} + onDragOver={handleDesignDrag} + onDrop={handleDesignDrop} + > + <Wrench className="mx-auto h-10 w-10 text-green-500 mb-3" /> + <p className="text-sm text-gray-600 mb-2"> + 설계 문서를 드래그하여 업로드 + </p> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => designInputRef.current?.click()} + className="border-green-500 text-green-600 hover:bg-green-50" + > + <Paperclip className="h-4 w-4 mr-2" /> + 설계 문서 선택 + </Button> + <input + ref={designInputRef} + type="file" + multiple + accept={acceptedFileTypes.all} + onChange={(e) => handleFileAdd(e.target.files, "설계")} + className="hidden" + /> + {designCount > 0 && ( + <div className="mt-2"> + <Badge variant="secondary">{designCount}개 업로드됨</Badge> + </div> + )} + {/* <p className="text-xs text-gray-500 mt-2"> + 최대 1GB, 여러 파일 선택 가능 + </p> */} + </div> + </CardContent> + </Card> + </div> + + {/* 첨부파일 목록 */} + {(attachments.length > 0 || existingAttachments.length > 0) && ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle>첨부파일 목록</CardTitle> + <div className="flex items-center gap-2"> + <Badge variant="outline" className="gap-1"> + <Calculator className="h-3 w-3" /> + 구매 {purchaseCount} + </Badge> + <Badge variant="outline" className="gap-1"> + <Wrench className="h-3 w-3" /> + 설계 {designCount} + </Badge> + <Badge variant="secondary"> + 총 {attachments.length + existingAttachments.length}개 + </Badge> + </div> + </div> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">유형</TableHead> + <TableHead>파일명</TableHead> + <TableHead className="w-[100px]">크기</TableHead> + <TableHead className="w-[120px]">문서 구분</TableHead> + <TableHead className="w-[100px]">상태</TableHead> + <TableHead className="w-[80px]">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {/* 기존 첨부파일 */} + {existingAttachments.map((file, index) => ( + <TableRow key={`existing-${index}`}> + <TableCell> + {getFileIcon(file.originalFileName)} + </TableCell> + <TableCell> + <div> + <p className="font-medium">{file.originalFileName}</p> + {file.description && ( + <p className="text-xs text-muted-foreground"> + {file.description} + </p> + )} + </div> + </TableCell> + <TableCell className="text-sm text-muted-foreground"> + {formatBytes(file.fileSize || 0)} + </TableCell> + <TableCell> + <Badge + variant={file.attachmentType === "구매" ? "default" : "secondary"} + className="gap-1" + > + {file.attachmentType === "구매" ? + <Calculator className="h-3 w-3" /> : + <Wrench className="h-3 w-3" /> + } + {file.attachmentType} + </Badge> + </TableCell> + <TableCell> + <Badge variant="secondary"> + <FileCheck className="h-3 w-3 mr-1" /> + 기존 + </Badge> + </TableCell> + <TableCell> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => window.open(file.filePath, '_blank')} + > + <Download className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ))} + + {/* 새로 추가된 파일 */} + {attachments.map((file, index) => ( + <TableRow key={`new-${index}`}> + <TableCell> + {getFileIcon(file.name)} + </TableCell> + <TableCell> + <div> + <p className="font-medium">{file.name}</p> + </div> + </TableCell> + <TableCell className="text-sm text-muted-foreground"> + {formatBytes(file.size)} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Button + type="button" + variant={file.attachmentType === "구매" ? "default" : "ghost"} + size="sm" + className="h-7 px-2 text-xs" + onClick={() => handleTypeChange(index, "구매")} + > + <Calculator className="h-3 w-3 mr-1" /> + 구매 + </Button> + <Button + type="button" + variant={file.attachmentType === "설계" ? "default" : "ghost"} + size="sm" + className="h-7 px-2 text-xs" + onClick={() => handleTypeChange(index, "설계")} + > + <Wrench className="h-3 w-3 mr-1" /> + 설계 + </Button> + </div> + </TableCell> + <TableCell> + <Badge variant="default"> + <Upload className="h-3 w-3 mr-1" /> + 신규 + </Badge> + </TableCell> + <TableCell> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleFileRemove(index)} + > + <Trash2 className="h-4 w-4 text-red-500" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + )} + </div> + ) +}
\ No newline at end of file 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 diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx new file mode 100644 index 00000000..08928b4d --- /dev/null +++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx @@ -0,0 +1,449 @@ +"use client" + +import { useFormContext, useFieldArray } from "react-hook-form" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" +import { Label } from "@/components/ui/label" +import { CalendarIcon, Eye, Calculator, AlertCircle } from "lucide-react" +import { format } from "date-fns" +import { cn, formatCurrency } from "@/lib/utils" +import { useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +interface QuotationItemsTableProps { + prItems: any[] +} + +export default function QuotationItemsTable({ prItems }: QuotationItemsTableProps) { + const { control, register, setValue, watch } = useFormContext() + const { fields } = useFieldArray({ + control, + name: "quotationItems" + }) + + const [selectedItem, setSelectedItem] = useState<any>(null) + const [showDetail, setShowDetail] = useState(false) + + const currency = watch("vendorCurrency") || "USD" + const quotationItems = watch("quotationItems") + + // 단가 * 수량 계산 + const calculateTotal = (index: number) => { + const item = quotationItems[index] + const prItem = prItems[index] + if (item && prItem) { + const total = (item.unitPrice || 0) * (prItem.quantity || 0) + setValue(`quotationItems.${index}.totalPrice`, total) + } + } + + // 할인 적용 + const applyDiscount = (index: number) => { + const item = quotationItems[index] + const prItem = prItems[index] + if (item && prItem && item.discountRate) { + const originalTotal = (item.unitPrice || 0) * (prItem.quantity || 0) + const discountAmount = originalTotal * (item.discountRate / 100) + const finalTotal = originalTotal - discountAmount + setValue(`quotationItems.${index}.totalPrice`, finalTotal) + } + } + + const totalAmount = quotationItems?.reduce( + (sum: number, item: any) => sum + (item.totalPrice || 0), 0 + ) || 0 + + // 상세 정보 다이얼로그 + const ItemDetailDialog = ({ item, prItem, index }: any) => ( + <Dialog open={showDetail} onOpenChange={setShowDetail}> + <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>견적 상세 정보</DialogTitle> + <DialogDescription> + {prItem.materialCode} - {prItem.materialDescription} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* PR 아이템 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">PR 아이템 정보</CardTitle> + </CardHeader> + <CardContent className="grid grid-cols-2 gap-4"> + <div> + <Label className="text-xs text-muted-foreground">PR 번호</Label> + <p className="font-medium">{prItem.prNo}</p> + </div> + <div> + <Label className="text-xs text-muted-foreground">자재 코드</Label> + <p className="font-medium">{prItem.materialCode}</p> + </div> + <div> + <Label className="text-xs text-muted-foreground">수량</Label> + <p className="font-medium">{prItem.quantity} {prItem.uom}</p> + </div> + <div> + <Label className="text-xs text-muted-foreground">요청 납기일</Label> + <p className="font-medium"> + {prItem.deliveryDate ? format(new Date(prItem.deliveryDate), "yyyy-MM-dd") : '-'} + </p> + </div> + {prItem.specNo && ( + <div> + <Label className="text-xs text-muted-foreground">스펙 번호</Label> + <p className="font-medium">{prItem.specNo}</p> + </div> + )} + {prItem.trackingNo && ( + <div> + <Label className="text-xs text-muted-foreground">추적 번호</Label> + <p className="font-medium">{prItem.trackingNo}</p> + </div> + )} + </CardContent> + </Card> + + {/* 제조사 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">제조사 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor={`manufacturer-${index}`}>제조사</Label> + <Input + id={`manufacturer-${index}`} + {...register(`quotationItems.${index}.manufacturer`)} + placeholder="제조사 입력" + /> + </div> + <div className="space-y-2"> + <Label htmlFor={`manufacturerCountry-${index}`}>제조국</Label> + <Input + id={`manufacturerCountry-${index}`} + {...register(`quotationItems.${index}.manufacturerCountry`)} + placeholder="제조국 입력" + /> + </div> + </div> + <div className="space-y-2"> + <Label htmlFor={`modelNo-${index}`}>모델 번호</Label> + <Input + id={`modelNo-${index}`} + {...register(`quotationItems.${index}.modelNo`)} + placeholder="모델 번호 입력" + /> + </div> + </CardContent> + </Card> + + {/* 기술 준수 및 대안 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">기술 사양</CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="flex items-center space-x-2"> + <Checkbox + id={`technicalCompliance-${index}`} + checked={watch(`quotationItems.${index}.technicalCompliance`)} + onCheckedChange={(checked) => + setValue(`quotationItems.${index}.technicalCompliance`, checked) + } + /> + <Label htmlFor={`technicalCompliance-${index}`}> + 기술 사양 준수 + </Label> + </div> + + {!watch(`quotationItems.${index}.technicalCompliance`) && ( + <div className="space-y-2"> + <Label htmlFor={`alternativeProposal-${index}`}> + 대안 제안 <span className="text-red-500">*</span> + </Label> + <Textarea + id={`alternativeProposal-${index}`} + {...register(`quotationItems.${index}.alternativeProposal`)} + placeholder="기술 사양을 준수하지 않는 경우 대안을 제시해주세요" + className="min-h-[100px]" + /> + </div> + )} + + <div className="space-y-2"> + <Label htmlFor={`deviationReason-${index}`}>편차 사유</Label> + <Textarea + id={`deviationReason-${index}`} + {...register(`quotationItems.${index}.deviationReason`)} + placeholder="요구사항과 다른 부분이 있는 경우 사유를 입력하세요" + className="min-h-[80px]" + /> + </div> + </CardContent> + </Card> + + {/* 할인 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">할인 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor={`discountRate-${index}`}>할인율 (%)</Label> + <Input + id={`discountRate-${index}`} + type="number" + step="0.01" + {...register(`quotationItems.${index}.discountRate`, { valueAsNumber: true })} + onChange={(e) => { + setValue(`quotationItems.${index}.discountRate`, parseFloat(e.target.value)) + applyDiscount(index) + }} + placeholder="0.00" + /> + </div> + <div className="space-y-2"> + <Label>할인 금액</Label> + <div className="h-10 px-3 py-2 border rounded-md bg-muted"> + {formatCurrency( + (watch(`quotationItems.${index}.unitPrice`) || 0) * + (prItem.quantity || 0) * + ((watch(`quotationItems.${index}.discountRate`) || 0) / 100), + currency + )} + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 비고 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">비고</CardTitle> + </CardHeader> + <CardContent> + <Textarea + {...register(`quotationItems.${index}.itemRemark`)} + placeholder="아이템별 비고사항을 입력하세요" + className="min-h-[100px]" + /> + </CardContent> + </Card> + </div> + </DialogContent> + </Dialog> + ) + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>견적 품목</CardTitle> + <CardDescription> + 각 PR 아이템에 대한 견적 단가와 정보를 입력하세요 + </CardDescription> + </div> + <div className="text-right"> + <p className="text-sm text-muted-foreground">총 견적금액</p> + <p className="text-2xl font-bold text-primary"> + {formatCurrency(totalAmount, currency)} + </p> + </div> + </div> + </CardHeader> + <CardContent> + <div className="overflow-x-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">No</TableHead> + <TableHead className="w-[100px]">PR No</TableHead> + <TableHead className="min-w-[150px]">자재코드</TableHead> + <TableHead className="min-w-[200px]">자재설명</TableHead> + <TableHead className="text-right w-[100px]">수량</TableHead> + <TableHead className="w-[150px]">단가</TableHead> + <TableHead className="text-right w-[150px]">총액</TableHead> + <TableHead className="w-[150px]">납기일</TableHead> + <TableHead className="w-[100px]">리드타임</TableHead> + <TableHead className="w-[80px]">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {fields.map((field, index) => { + const prItem = prItems[index] + const quotationItem = quotationItems[index] + const isMajor = prItem?.majorYn + + return ( + <TableRow key={field.id} className={isMajor ? "bg-yellow-50" : ""}> + <TableCell> + <div className="flex items-center gap-2"> + {prItem?.rfqItem || index + 1} + {isMajor && ( + <Badge variant="secondary" className="text-xs"> + 주요 + </Badge> + )} + </div> + </TableCell> + <TableCell className="font-mono text-xs"> + {prItem?.prNo} + </TableCell> + <TableCell className="font-mono text-xs"> + {prItem?.materialCode} + </TableCell> + <TableCell> + <div className="max-w-[200px]"> + <p className="truncate text-sm" title={prItem?.materialDescription}> + {prItem?.materialDescription} + </p> + {prItem?.size && ( + <p className="text-xs text-muted-foreground"> + 사이즈: {prItem.size} + </p> + )} + </div> + </TableCell> + <TableCell className="text-right"> + {prItem?.quantity} {prItem?.uom} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Input + type="number" + step="0.01" + {...register(`quotationItems.${index}.unitPrice`, { valueAsNumber: true })} + onChange={(e) => { + setValue(`quotationItems.${index}.unitPrice`, parseFloat(e.target.value)) + calculateTotal(index) + }} + className="w-[120px]" + placeholder="0.00" + /> + <span className="text-xs text-muted-foreground"> + {currency} + </span> + </div> + </TableCell> + <TableCell className="text-right font-medium"> + {formatCurrency(quotationItem?.totalPrice || 0, currency)} + </TableCell> + <TableCell> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + size="sm" + className={cn( + "w-[130px] justify-start text-left font-normal", + !quotationItem?.vendorDeliveryDate && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-3 w-3" /> + {quotationItem?.vendorDeliveryDate + ? format(quotationItem.vendorDeliveryDate, "yyyy-MM-dd") + : "선택"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={quotationItem?.vendorDeliveryDate} + onSelect={(date) => setValue(`quotationItems.${index}.vendorDeliveryDate`, date)} + initialFocus + /> + </PopoverContent> + </Popover> + {prItem?.deliveryDate && quotationItem?.vendorDeliveryDate && + new Date(quotationItem.vendorDeliveryDate) > new Date(prItem.deliveryDate) && ( + <div className="mt-1"> + <Badge variant="destructive" className="text-xs"> + 지연 + </Badge> + </div> + )} + </TableCell> + <TableCell> + <Input + type="number" + {...register(`quotationItems.${index}.leadTime`, { valueAsNumber: true })} + className="w-[80px]" + placeholder="일" + /> + </TableCell> + <TableCell> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => { + setSelectedItem({ item: quotationItem, prItem, index }) + setShowDetail(true) + }} + > + <Eye className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </div> + + {/* 총액 요약 */} + <div className="mt-4 flex justify-end"> + <Card className="w-[400px]"> + <CardContent className="pt-4"> + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span className="text-muted-foreground">소계</span> + <span>{formatCurrency(totalAmount, currency)}</span> + </div> + <div className="flex justify-between text-sm"> + <span className="text-muted-foreground">통화</span> + <span>{currency}</span> + </div> + <div className="border-t pt-2"> + <div className="flex justify-between"> + <span className="font-semibold">총 견적금액</span> + <span className="text-xl font-bold text-primary"> + {formatCurrency(totalAmount, currency)} + </span> + </div> + </div> + </div> + </CardContent> + </Card> + </div> + </CardContent> + + {/* 상세 다이얼로그 */} + {selectedItem && ( + <ItemDetailDialog + item={selectedItem.item} + prItem={selectedItem.prItem} + index={selectedItem.index} + /> + )} + </Card> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/editor/rfq-info-header.tsx b/lib/rfq-last/vendor-response/editor/rfq-info-header.tsx new file mode 100644 index 00000000..1078b20e --- /dev/null +++ b/lib/rfq-last/vendor-response/editor/rfq-info-header.tsx @@ -0,0 +1,213 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { formatDate } from "@/lib/utils" +import { Building2, Package, Calendar, FileText, User, Users, Ship, Award, Anchor, ArrowLeft } from "lucide-react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button"; + +interface RfqInfoHeaderProps { + rfq: any + rfqDetail: any + vendor: any +} + +export default function RfqInfoHeader({ rfq, rfqDetail, vendor }: RfqInfoHeaderProps) { + const majorMaterial = rfq.rfqPrItems?.find(v => v.majorYn) + const router = useRouter() + + const handleGoBack = () => { + router.push("/partners/rfq-last"); + }; + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle className="text-2xl"> + 견적서 작성 + <Badge variant="outline" className="ml-3"> + {rfq.rfqCode} + </Badge> + </CardTitle> + <Button + variant="ghost" + size="sm" + onClick={handleGoBack} + className="gap-2" + > + <ArrowLeft className="h-4 w-4" /> + 목록으로 + </Button> + </div> + </CardHeader> + <CardContent className="space-y-4"> + {/* 기본 정보 섹션 */} + <div className="grid grid-cols-2 lg:grid-cols-4 gap-6"> + {/* 프로젝트 정보 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Building2 className="h-4 w-4" /> + <span>프로젝트</span> + </div> + <p className="font-medium">{rfq.project?.name || '-'}</p> + <p className="text-xs text-muted-foreground">{rfq.project?.code || '-'}</p> + </div> + + {/* 패키지 정보 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Package className="h-4 w-4" /> + <span>패키지</span> + </div> + <p className="font-medium">{rfq.packageName || '-'}</p> + <p className="text-xs text-muted-foreground">{rfq.packageNo || '-'}</p> + </div> + + {/* 자재 그룹 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Package className="h-4 w-4" /> + <span>자재 그룹</span> + </div> + <p className="font-medium">{majorMaterial?.materialCategory || '-'}</p> + <p className="text-xs text-muted-foreground">{majorMaterial?.materialDescription || '-'}</p> + </div> + + {/* 마감일 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Calendar className="h-4 w-4" /> + <span>마감일</span> + </div> + <p className="font-medium"> + {rfq.dueDate ? formatDate(new Date(rfq.dueDate)) : '-'} + </p> + {rfq.dueDate && ( + <p className="text-xs text-muted-foreground"> + {(() => { + const now = new Date() + const dueDate = new Date(rfq.dueDate) + const diffTime = dueDate.getTime() - now.getTime() + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + + if (diffDays < 0) { + return <span className="text-red-600">마감일 초과</span> + } else if (diffDays === 0) { + return <span className="text-orange-600">오늘 마감</span> + } else if (diffDays === 1) { + return <span className="text-orange-600">내일 마감</span> + } else { + return `${diffDays}일 남음` + } + })()} + </p> + )} + </div> + + {/* 구매담당자 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <User className="h-4 w-4" /> + <span>구매담당자</span> + </div> + <p className="font-medium">{rfq.picName || '-'}</p> + </div> + + {/* 설계담당자 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <User className="h-4 w-4" /> + <span>설계담당자</span> + </div> + <p className="font-medium">{rfq.EngPicName || '-'}</p> + </div> + + {/* RFQ 제목 및 타입 (F로 시작하는 경우만) */} + {rfq.rfqCode && rfq.rfqCode.startsWith("F") && ( + <> + <div className="space-y-1 col-span-2"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <FileText className="h-4 w-4" /> + <span>견적 제목</span> + </div> + <p className="font-medium">{rfq.rfqTitle || '-'}</p> + </div> + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <FileText className="h-4 w-4" /> + <span>견적 종류</span> + </div> + <p className="font-medium">{rfq.rfqType || '-'}</p> + </div> + </> + )} + </div> + + {/* 프로젝트 상세 정보 섹션 (별도 div) */} + + <div className="pt-4 border-t"> + <h3 className="text-sm font-semibold mb-3">프로젝트 상세 정보</h3> + <div className="grid grid-cols-2 lg:grid-cols-5 gap-4"> + {/* 고객정보 */} + {( + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Users className="h-4 w-4" /> + <span>고객정보</span> + </div> + <p className="font-medium">{rfq.project?.OWN_NM || '-'}</p> + </div> + )} + + {/* 선급 */} + {( + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Award className="h-4 w-4" /> + <span>선급</span> + </div> + <p className="font-medium">{rfq.project?.CLS_1 || '-'}</p> + </div> + )} + + {/* 선종 */} + {( + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Ship className="h-4 w-4" /> + <span>선종</span> + </div> + <p className="font-medium">{rfq.project?.SKND || '-'}</p> + </div> + )} + + {/* 척수 */} + {( + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Anchor className="h-4 w-4" /> + <span>척수</span> + </div> + <p className="font-medium">{rfq.project?.TOT_CNRT_CNT || '-'}척</p> + </div> + )} + + {/* 계약일 */} + {( + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Calendar className="h-4 w-4" /> + <span>계약일</span> + </div> + <p className="font-medium">{rfq.project && rfq.project.CNRT_DT ? formatDate(new Date(rfq.project.CNRT_DT)) : '-'}</p> + </div> + )} + </div> + </div> + + </CardContent> + </Card> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx new file mode 100644 index 00000000..c146e42b --- /dev/null +++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx @@ -0,0 +1,477 @@ +"use client" + +import { useState } from "react" +import { useForm, FormProvider } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { useRouter } from "next/navigation" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" +import { toast } from "sonner" +import RfqInfoHeader from "./rfq-info-header" +import CommercialTermsForm from "./commercial-terms-form" +import QuotationItemsTable from "./quotation-items-table" +import AttachmentsUpload from "./attachments-upload" +import { formatDate, formatCurrency } from "@/lib/utils" +import { Shield, FileText, CheckCircle, XCircle, Clock, Download, Eye, Save, Send, AlertCircle, Upload, } from "lucide-react" +import { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription } from "@/components/ui/alert" + +// 폼 스키마 정의 +const vendorResponseSchema = z.object({ + // 상업 조건 + vendorCurrency: z.string().optional(), + vendorPaymentTermsCode: z.string().optional(), + vendorIncotermsCode: z.string().optional(), + vendorIncotermsDetail: z.string().optional(), + vendorDeliveryDate: z.date().optional().nullable(), + vendorContractDuration: z.string().optional(), + vendorTaxCode: z.string().optional(), + vendorPlaceOfShipping: z.string().optional(), + vendorPlaceOfDestination: z.string().optional(), + + // 초도품관리 + vendorFirstYn: z.boolean().optional(), + vendorFirstDescription: z.string().optional(), + vendorFirstAcceptance: z.enum(["수용", "부분수용", "거부"]).optional().nullable(), + + // Spare part + vendorSparepartYn: z.boolean().optional(), + vendorSparepartDescription: z.string().optional(), + vendorSparepartAcceptance: z.enum(["수용", "부분수용", "거부"]).optional().nullable(), + + // 연동제 + vendorMaterialPriceRelatedYn: z.boolean().optional(), + vendorMaterialPriceRelatedReason: z.string().optional(), + + // 변경 사유 + currencyReason: z.string().optional(), + paymentTermsReason: z.string().optional(), + deliveryDateReason: z.string().optional(), + incotermsReason: z.string().optional(), + taxReason: z.string().optional(), + shippingReason: z.string().optional(), + + // 비고 + generalRemark: z.string().optional(), + technicalProposal: z.string().optional(), + + // 견적 아이템 + quotationItems: z.array(z.object({ + rfqPrItemId: z.number(), + unitPrice: z.number().min(0), + totalPrice: z.number().min(0), + vendorDeliveryDate: z.date().optional().nullable(), + leadTime: z.number().optional(), + manufacturer: z.string().optional(), + manufacturerCountry: z.string().optional(), + modelNo: z.string().optional(), + technicalCompliance: z.boolean(), + alternativeProposal: z.string().optional(), + discountRate: z.number().optional(), + itemRemark: z.string().optional(), + deviationReason: z.string().optional(), + })) +}) + +type VendorResponseFormData = z.infer<typeof vendorResponseSchema> + +interface VendorResponseEditorProps { + rfq: any + rfqDetail: any + prItems: any[] + vendor: any + existingResponse?: any + userId: number + basicContracts?: any[] // 추가 +} + +export default function VendorResponseEditor({ + rfq, + rfqDetail, + prItems, + vendor, + existingResponse, + userId, + basicContracts = [] // 추가 + +}: VendorResponseEditorProps) { + const router = useRouter() + const [loading, setLoading] = useState(false) + const [activeTab, setActiveTab] = useState("info") + const [attachments, setAttachments] = useState<File[]>([]) + const [uploadProgress, setUploadProgress] = useState(0) // 추가 + + + // Form 초기값 설정 + const defaultValues: VendorResponseFormData = { + vendorCurrency: existingResponse?.vendorCurrency || rfqDetail.currency, + vendorPaymentTermsCode: existingResponse?.vendorPaymentTermsCode || rfqDetail.paymentTermsCode, + vendorIncotermsCode: existingResponse?.vendorIncotermsCode || rfqDetail.incotermsCode, + vendorIncotermsDetail: existingResponse?.vendorIncotermsDetail || rfqDetail.incotermsDetail, + vendorDeliveryDate: existingResponse?.vendorDeliveryDate ? new Date(existingResponse.vendorDeliveryDate) : + rfqDetail.deliveryDate ? new Date(rfqDetail.deliveryDate) : null, + vendorContractDuration: existingResponse?.vendorContractDuration || rfqDetail.contractDuration, + vendorTaxCode: existingResponse?.vendorTaxCode || rfqDetail.taxCode, + vendorPlaceOfShipping: existingResponse?.vendorPlaceOfShipping || rfqDetail.placeOfShipping, + vendorPlaceOfDestination: existingResponse?.vendorPlaceOfDestination || rfqDetail.placeOfDestination, + + vendorFirstYn: existingResponse?.vendorFirstYn ?? rfqDetail.firstYn, + vendorFirstDescription: existingResponse?.vendorFirstDescription || "", + vendorFirstAcceptance: existingResponse?.vendorFirstAcceptance || null, + + vendorSparepartYn: existingResponse?.vendorSparepartYn ?? rfqDetail.sparepartYn, + vendorSparepartDescription: existingResponse?.vendorSparepartDescription || "", + vendorSparepartAcceptance: existingResponse?.vendorSparepartAcceptance || null, + + vendorMaterialPriceRelatedYn: existingResponse?.vendorMaterialPriceRelatedYn ?? rfqDetail.materialPriceRelatedYn, + vendorMaterialPriceRelatedReason: existingResponse?.vendorMaterialPriceRelatedReason || "", + + currencyReason: existingResponse?.currencyReason || "", + paymentTermsReason: existingResponse?.paymentTermsReason || "", + deliveryDateReason: existingResponse?.deliveryDateReason || "", + incotermsReason: existingResponse?.incotermsReason || "", + taxReason: existingResponse?.taxReason || "", + shippingReason: existingResponse?.shippingReason || "", + + generalRemark: existingResponse?.generalRemark || "", + technicalProposal: existingResponse?.technicalProposal || "", + + quotationItems: prItems.map(item => { + const existingItem = existingResponse?.quotationItems?.find( + (q: any) => q.rfqPrItemId === item.id + ) + return { + rfqPrItemId: item.id, + unitPrice: existingItem?.unitPrice || 0, + totalPrice: existingItem?.totalPrice || 0, + vendorDeliveryDate: existingItem?.vendorDeliveryDate ? new Date(existingItem.vendorDeliveryDate) : null, + leadTime: existingItem?.leadTime || undefined, + manufacturer: existingItem?.manufacturer || "", + manufacturerCountry: existingItem?.manufacturerCountry || "", + modelNo: existingItem?.modelNo || "", + technicalCompliance: existingItem?.technicalCompliance ?? true, + alternativeProposal: existingItem?.alternativeProposal || "", + discountRate: existingItem?.discountRate || undefined, + itemRemark: existingItem?.itemRemark || "", + deviationReason: existingItem?.deviationReason || "", + } + }) + } + + const methods = useForm<VendorResponseFormData>({ + resolver: zodResolver(vendorResponseSchema), + defaultValues + }) + + const onSubmit = async (data: VendorResponseFormData, isSubmit: boolean = false) => { + setLoading(true) + setUploadProgress(0) + + try { + const formData = new FormData() + + // 기본 데이터 추가 + formData.append('data', JSON.stringify({ + ...data, + rfqsLastId: rfq.id, + rfqLastDetailsId: rfqDetail.id, + vendorId: vendor.id, + status: isSubmit ? "제출완료" : "작성중", + submittedAt: isSubmit ? new Date().toISOString() : null, + submittedBy: isSubmit ? userId : null, + totalAmount: data.quotationItems.reduce((sum, item) => sum + item.totalPrice, 0), + updatedBy: userId + })) + + // 첨부파일 추가 + attachments.forEach((file, index) => { + formData.append(`attachments`, file) + }) + + // const response = await fetch(`/api/partners/rfq-last/${rfq.id}/response`, { + // method: existingResponse ? 'PUT' : 'POST', + // body: formData + // }) + + // if (!response.ok) { + // throw new Error('응답 저장에 실패했습니다.') + // } + + // XMLHttpRequest 사용하여 업로드 진행률 추적 + const xhr = new XMLHttpRequest() + + // Promise로 감싸서 async/await 사용 가능하게 + const uploadPromise = new Promise((resolve, reject) => { + // 업로드 진행률 이벤트 + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const percentComplete = Math.round((event.loaded / event.total) * 100) + setUploadProgress(percentComplete) + } + }) + + // 완료 이벤트 + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + setUploadProgress(100) + resolve(JSON.parse(xhr.responseText)) + } else { + reject(new Error('응답 저장에 실패했습니다.')) + } + }) + + // 에러 이벤트 + xhr.addEventListener('error', () => { + reject(new Error('네트워크 오류가 발생했습니다.')) + }) + + // 요청 전송 + xhr.open(existingResponse ? 'PUT' : 'POST', `/api/partners/rfq-last/${rfq.id}/response`) + xhr.send(formData) + }) + + await uploadPromise + + toast.success(isSubmit ? "견적서가 제출되었습니다." : "견적서가 저장되었습니다.") + router.push('/partners/rfq-last') + router.refresh() + } catch (error) { + console.error('Error:', error) + toast.error("오류가 발생했습니다.") + } finally { + setLoading(false) + } + } + + const totalAmount = methods.watch('quotationItems')?.reduce( + (sum, item) => sum + (item.totalPrice || 0), 0 + ) || 0 + + + const allContractsSigned = basicContracts.length === 0 || + basicContracts.every(contract => contract.signedAt); + + return ( + <FormProvider {...methods}> + <form onSubmit={methods.handleSubmit((data) => onSubmit(data, false))}> + <div className="space-y-6"> + {/* 헤더 정보 */} + <RfqInfoHeader rfq={rfq} rfqDetail={rfqDetail} vendor={vendor} /> + + {/* 견적 총액 표시 */} + {totalAmount > 0 && ( + <Card> + <CardContent className="pt-6"> + <div className="flex items-center justify-between"> + <span className="text-lg font-medium">견적 총액</span> + <span className="text-2xl font-bold text-primary"> + {formatCurrency(totalAmount, methods.watch('vendorCurrency') || 'USD')} + </span> + </div> + </CardContent> + </Card> + )} + + {/* 탭 콘텐츠 */} + <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> + <TabsList className="grid w-full grid-cols-4"> + <TabsTrigger value="info">기본계약</TabsTrigger> + <TabsTrigger value="terms">상업조건</TabsTrigger> + <TabsTrigger value="items">견적품목</TabsTrigger> + <TabsTrigger value="attachments">첨부파일</TabsTrigger> + </TabsList> + + <TabsContent value="info" className="mt-6"> + <Card> + <CardHeader> + <CardTitle>기본계약 정보</CardTitle> + <CardDescription> + 이 RFQ에 요청된 기본계약 목록 및 상태입니다 + </CardDescription> + </CardHeader> + <CardContent> + {basicContracts.length > 0 ? ( + <div className="space-y-4"> + {/* 계약 목록 - 그리드 레이아웃 */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> + {basicContracts.map((contract) => ( + <div + key={contract.id} + className="p-3 border rounded-lg bg-card hover:bg-muted/50 transition-colors" + > + <div className="flex items-start gap-2"> + <div className="p-1.5 bg-primary/10 rounded"> + <Shield className="h-3.5 w-3.5 text-primary" /> + </div> + <div className="flex-1 min-w-0"> + <h4 className="font-medium text-sm truncate" title={contract.templateName}> + {contract.templateName} + </h4> + <Badge + variant={contract.signedAt ? "success" : "warning"} + className="text-xs mt-1.5" + > + {contract.signedAt ? ( + <> + <CheckCircle className="h-3 w-3 mr-1" /> + 서명완료 + </> + ) : ( + <> + <Clock className="h-3 w-3 mr-1" /> + 서명대기 + </> + )} + </Badge> + <p className="text-xs text-muted-foreground mt-1"> + {contract.signedAt + ? `${formatDate(new Date(contract.signedAt))}` + : contract.deadline + ? `~${formatDate(new Date(contract.deadline))}` + : '마감일 없음'} + </p> + </div> + </div> + </div> + ))} + </div> + + {/* 서명 상태 요약 및 액션 */} + {basicContracts.some(contract => !contract.signedAt) ? ( + <div className="flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg"> + <div className="flex items-center gap-2"> + <AlertCircle className="h-4 w-4 text-amber-600" /> + <div> + <p className="text-sm font-medium"> + 서명 대기: {basicContracts.filter(c => !c.signedAt).length}/{basicContracts.length}개 + </p> + <p className="text-xs text-muted-foreground"> + 견적서 제출 전 모든 계약서 서명 필요 + </p> + </div> + </div> + <Button + type="button" + size="sm" + onClick={() => router.push(`/partners/basic-contract`)} + > + 서명하기 + </Button> + </div> + ) : ( + <Alert className="border-green-200 bg-green-50 dark:bg-green-950/20"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <AlertDescription className="text-sm"> + 모든 기본계약 서명 완료 + </AlertDescription> + </Alert> + )} + </div> + ) : ( + <div className="text-center py-8"> + <FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> + <p className="text-muted-foreground"> + 이 RFQ에 요청된 기본계약이 없습니다 + </p> + </div> + )} +</CardContent> + </Card> + </TabsContent> + + <TabsContent value="terms" className="mt-6"> + <CommercialTermsForm rfqDetail={rfqDetail} /> + </TabsContent> + + <TabsContent value="items" className="mt-6"> + <QuotationItemsTable prItems={prItems} /> + </TabsContent> + + <TabsContent value="attachments" className="mt-6"> + <AttachmentsUpload + attachments={attachments} + onAttachmentsChange={setAttachments} + existingAttachments={existingResponse?.attachments} + /> + </TabsContent> + </Tabs> + + {/* 하단 액션 버튼 */} + {loading && uploadProgress > 0 && ( + <Card> + <CardContent className="pt-6"> + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span className="flex items-center gap-2"> + <Upload className="h-4 w-4 animate-pulse" /> + 파일 업로드 중... + </span> + <span className="font-medium">{uploadProgress}%</span> + </div> + <Progress value={uploadProgress} className="h-2" /> + <p className="text-xs text-muted-foreground"> + 대용량 파일 업로드 시 시간이 걸릴 수 있습니다. 창을 닫지 마세요. + </p> + </div> + </CardContent> + </Card> + )} + <div className="flex justify-end gap-3"> + <Button + type="button" + variant="outline" + onClick={() => router.back()} + disabled={loading} + > + 취소 + </Button> + <Button + type="submit" + variant="secondary" + disabled={loading} + > + {loading ? ( + <> + <div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" /> + 처리중... + </> + ) : ( + <> + <Save className="h-4 w-4 mr-2" /> + 임시저장 + </> + )} + </Button> + <Button + type="button" + variant="default" + onClick={methods.handleSubmit((data) => onSubmit(data, true))} + disabled={loading || !allContractsSigned} + > + {!allContractsSigned ? ( + <> + <AlertCircle className="h-4 w-4 mr-2" /> + 기본계약 서명 필요 + </> + ) : loading ? ( + <> + <div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" /> + 처리중... + </> + ) : ( + <> + <Send className="h-4 w-4 mr-2" /> + 견적서 제출 + </> + )} + </Button> + </div> + + </div> + </form> + </FormProvider> + ) +}
\ No newline at end of file |
