diff options
Diffstat (limited to 'lib/rfq-last/vendor-response')
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/attachments-upload.tsx | 466 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx | 713 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/quotation-items-table.tsx | 449 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/rfq-info-header.tsx | 213 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx | 477 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/participation-dialog.tsx | 230 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx | 407 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/rfq-items-dialog.tsx | 354 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/service.ts | 483 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/validations.ts | 42 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx | 514 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/vendor-quotations-table.tsx | 171 |
12 files changed, 4519 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 diff --git a/lib/rfq-last/vendor-response/participation-dialog.tsx b/lib/rfq-last/vendor-response/participation-dialog.tsx new file mode 100644 index 00000000..a7337ac2 --- /dev/null +++ b/lib/rfq-last/vendor-response/participation-dialog.tsx @@ -0,0 +1,230 @@ +// components/vendor/participation-dialog.tsx + +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { useToast } from "@/hooks/use-toast" +import { updateParticipationStatus } from "@/lib/rfq-last/vendor-response/service" +import { CheckCircle, XCircle } from "lucide-react" + +interface ParticipationDialogProps { + rfqId: number + rfqCode: string + rfqLastDetailsId: number + currentStatus?: string + onClose: () => void +} + +export function ParticipationDialog({ + rfqId, + rfqCode, + rfqLastDetailsId, + currentStatus, + onClose, +}: ParticipationDialogProps) { + const router = useRouter() + const [showDeclineDialog, setShowDeclineDialog] = React.useState(false) + const [showConfirmDialog, setShowConfirmDialog] = React.useState(false) + const [declineReason, setDeclineReason] = React.useState("") + const [isLoading, setIsLoading] = React.useState(false) + const { toast } = useToast(); + + const handleParticipate = () => { + setShowConfirmDialog(true) + } + + const handleDecline = () => { + setShowDeclineDialog(true) + } + + const confirmParticipation = async () => { + setIsLoading(true) + try { + const result = await updateParticipationStatus({ + rfqId, + rfqLastDetailsId, + participationStatus: "참여", + }) + + if (result.success) { + toast({ + title: "참여 확정", + description: result.message, + }) + // router.push(`/partners/rfq-last/${rfqId}`) + router.refresh() + } else { + toast({ + title: "오류", + description: result.message, + variant: "destructive", + }) + } + } catch (error) { + toast({ + title: "오류", + description: "참여 처리 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + onClose() + } + } + + const confirmDecline = async () => { + if (!declineReason.trim()) { + toast({ + title: "불참 사유 필요", + description: "불참 사유를 입력해주세요.", + variant: "destructive", + }) + return + } + + setIsLoading(true) + try { + const result = await updateParticipationStatus({ + rfqId, + rfqLastDetailsId, + participationStatus: "불참", + nonParticipationReason: declineReason, + }) + + if (result.success) { + toast({ + title: "불참 처리 완료", + description: result.message, + }) + router.refresh() + } else { + toast({ + title: "오류", + description: result.message, + variant: "destructive", + }) + } + } catch (error) { + toast({ + title: "오류", + description: "불참 처리 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + onClose() + } + } + + return ( + <> + {/* 메인 다이얼로그 */} + <AlertDialog open={true} onOpenChange={onClose}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>견적 참여 여부 결정</AlertDialogTitle> + <AlertDialogDescription> + {rfqCode} 견적 요청에 참여하시겠습니까? + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <Button + variant="outline" + onClick={handleDecline} + disabled={isLoading} + > + <XCircle className="mr-2 h-4 w-4" /> + 불참 + </Button> + <Button + onClick={handleParticipate} + disabled={isLoading} + > + <CheckCircle className="mr-2 h-4 w-4" /> + 참여 + </Button> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + + {/* 참여 확인 다이얼로그 */} + <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>견적 참여 확정</AlertDialogTitle> + <AlertDialogDescription> + 견적 참여를 확정하시면 견적서 작성 페이지로 이동합니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction onClick={confirmParticipation}> + 확정 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + + {/* 불참 사유 입력 다이얼로그 */} + <Dialog open={showDeclineDialog} onOpenChange={setShowDeclineDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>견적 불참</DialogTitle> + <DialogDescription> + 불참 사유를 입력해주세요. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="grid gap-2"> + <Label htmlFor="reason">불참 사유</Label> + <Textarea + id="reason" + placeholder="불참 사유를 입력하세요..." + value={declineReason} + onChange={(e) => setDeclineReason(e.target.value)} + rows={4} + /> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + onClick={() => setShowDeclineDialog(false)} + > + 취소 + </Button> + <Button + onClick={confirmDecline} + disabled={isLoading || !declineReason.trim()} + > + 불참 확정 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx new file mode 100644 index 00000000..cfe24d73 --- /dev/null +++ b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx @@ -0,0 +1,407 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { Download, FileText, Eye, ExternalLink, Loader2 } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "sonner" +import { RfqsLastView } from "@/db/schema" +import { getRfqAttachmentsAction } from "../service" +import { downloadFile, quickPreview, smartFileAction, formatFileSize, getFileInfo } from "@/lib/file-download" + +// 첨부파일 타입 +interface RfqAttachment { + attachmentId: number + attachmentType: string + serialNo: string + description: string | null + currentRevision: string + fileName: string + originalFileName: string + filePath: string + fileSize: number | null + fileType: string | null + createdByName: string | null + createdAt: Date | null + updatedAt: Date | null + revisionComment?: string | null +} + +interface RfqAttachmentsDialogProps { + isOpen: boolean + onClose: () => void + rfqData: RfqsLastView +} + +export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachmentsDialogProps) { + const [attachments, setAttachments] = React.useState<RfqAttachment[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [downloadingFiles, setDownloadingFiles] = React.useState<Set<number>>(new Set()) + const [isDownloadingAll, setIsDownloadingAll] = React.useState(false); + + + const handleDownloadAll = async () => { + setIsDownloadingAll(true); + try { + + const rfqId = rfqData.id + + const attachments = await getRfqAttachmentsAction(rfqId); + + if (!attachments.success || attachments.data.length === 0) { + toast.error(result.error || "다운로드할 파일이 없습니다"); + } + + + // 2. ZIP 파일 생성 (서버에서) + const response = await fetch(`/api/rfq/attachments/download-all`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rfqId, + files: attachments.data.map(a => ({ + path: a.filePath, + name: a.originalFileName + })) + }) + }); + + if (!response.ok) throw new Error('ZIP 생성 실패'); + + // 3. ZIP 다운로드 + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + // RFQ 코드를 포함한 파일명 + const date = new Date().toISOString().slice(0, 10); + link.download = `RFQ_${rfqId}_attachments_${date}.zip`; + link.href = url; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + } finally { + setIsDownloadingAll(false); + } + }; + + // 첨부파일 목록 로드 + React.useEffect(() => { + if (!isOpen || !rfqData.id) return + + const loadAttachments = async () => { + setIsLoading(true) + try { + const result = await getRfqAttachmentsAction(rfqData.id) + + if (result.success) { + setAttachments(result.data) + } else { + toast.error(result.error || "첨부파일을 불러오는데 실패했습니다") + setAttachments([]) + } + } catch (error) { + console.error("첨부파일 로드 오류:", error) + toast.error("첨부파일을 불러오는데 실패했습니다") + setAttachments([]) + } finally { + setIsLoading(false) + } + } + + loadAttachments() + }, [isOpen, rfqData.id]) + + // 파일 다운로드 핸들러 + const handleDownload = async (attachment: RfqAttachment) => { + const attachmentId = attachment.attachmentId + setDownloadingFiles(prev => new Set([...prev, attachmentId])) + + try { + const result = await downloadFile( + attachment.filePath, + attachment.originalFileName, + { + action: 'download', + showToast: true, + showSuccessToast: true, + onSuccess: (fileName, fileSize) => { + console.log(`다운로드 완료: ${fileName} (${formatFileSize(fileSize || 0)})`) + }, + onError: (error) => { + console.error(`다운로드 실패: ${error}`) + } + } + ) + + if (!result.success) { + console.error("다운로드 결과:", result) + } + } catch (error) { + console.error("파일 다운로드 오류:", error) + toast.error("파일 다운로드에 실패했습니다") + } finally { + setDownloadingFiles(prev => { + const newSet = new Set(prev) + newSet.delete(attachmentId) + return newSet + }) + } + } + + // 파일 미리보기 핸들러 + const handlePreview = async (attachment: RfqAttachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + + if (!fileInfo.canPreview) { + toast.info("이 파일 형식은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다.") + return handleDownload(attachment) + } + + try { + const result = await quickPreview(attachment.filePath, attachment.originalFileName) + + if (!result.success) { + console.error("미리보기 결과:", result) + } + } catch (error) { + console.error("파일 미리보기 오류:", error) + toast.error("파일 미리보기에 실패했습니다") + } + } + + + // 첨부파일 타입별 색상 + const getAttachmentTypeBadgeVariant = (type: string) => { + switch (type.toLowerCase()) { + case "견적요청서": return "default" + case "기술사양서": return "secondary" + case "도면": return "outline" + default: return "outline" + } + } + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-6xl h-[85vh] flex flex-col"> + <DialogHeader> + <div className="flex items-center justify-between"> + <div> + <DialogTitle>견적 첨부파일</DialogTitle> + <DialogDescription> + {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "견적"} + {attachments.length > 0 && ` (${attachments.length}개 파일)`} + </DialogDescription> + </div> + + </div> + </DialogHeader> + + <ScrollArea className="flex-1"> + {isLoading ? ( + <div className="space-y-3"> + {[...Array(3)].map((_, i) => ( + <div key={i} className="flex items-center space-x-4 p-3 border rounded-lg"> + <Skeleton className="h-8 w-8" /> + <div className="space-y-2 flex-1"> + <Skeleton className="h-4 w-[300px]" /> + <Skeleton className="h-3 w-[200px]" /> + </div> + <div className="flex gap-2"> + <Skeleton className="h-8 w-20" /> + <Skeleton className="h-8 w-20" /> + </div> + </div> + ))} + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">타입</TableHead> + <TableHead>파일명</TableHead> + {/* <TableHead>설명</TableHead> */} + <TableHead className="w-[90px]">리비전</TableHead> + <TableHead className="w-[100px]">크기</TableHead> + <TableHead className="w-[120px]">생성자</TableHead> + <TableHead className="w-[120px]">생성일</TableHead> + <TableHead className="w-[140px]">액션</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {attachments.length === 0 ? ( + <TableRow> + <TableCell colSpan={8} className="text-center text-muted-foreground py-12"> + <div className="flex flex-col items-center gap-2"> + <FileText className="h-8 w-8 text-muted-foreground" /> + <span>첨부된 파일이 없습니다.</span> + </div> + </TableCell> + </TableRow> + ) : ( + attachments.map((attachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + const isDownloading = downloadingFiles.has(attachment.attachmentId) + + return ( + <TableRow key={attachment.attachmentId}> + <TableCell> + <Badge + variant={getAttachmentTypeBadgeVariant(attachment.attachmentType)} + className="text-xs" + > + {attachment.attachmentType} + </Badge> + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + <span className="text-lg">{fileInfo.icon}</span> + <div className="flex flex-col min-w-0"> + <span className="text-sm font-medium truncate" title={attachment.originalFileName}> + {attachment.originalFileName} + </span> + </div> + </div> + </TableCell> + {/* <TableCell> + <span className="text-sm" title={attachment.description || ""}> + {attachment.description || "-"} + </span> + {attachment.revisionComment && ( + <div className="text-xs text-muted-foreground mt-1"> + {attachment.revisionComment} + </div> + )} + </TableCell> */} + <TableCell> + <Badge variant="secondary" className="font-mono text-xs"> + {attachment.currentRevision} + </Badge> + </TableCell> + <TableCell className="text-xs text-muted-foreground"> + {attachment.fileSize ? formatFileSize(attachment.fileSize) : "-"} + </TableCell> + <TableCell className="text-sm"> + {attachment.createdByName || "-"} + </TableCell> + <TableCell className="text-xs text-muted-foreground"> + {attachment.createdAt ? format(new Date(attachment.createdAt), "MM-dd HH:mm") : "-"} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + {/* 미리보기 버튼 (미리보기 가능한 파일만) */} + {fileInfo.canPreview && ( + <Button + variant="ghost" + size="sm" + onClick={() => handlePreview(attachment)} + disabled={isDownloading} + title="미리보기" + > + <Eye className="h-4 w-4" /> + </Button> + )} + + {/* 다운로드 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => handleDownload(attachment)} + disabled={isDownloading} + title="다운로드" + > + {isDownloading ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + + {/* 스마트 액션 버튼 (메인 액션) */} + {/* <Button + variant="outline" + size="sm" + onClick={() => handleSmartAction(attachment)} + disabled={isDownloading} + className="ml-1" + > + {isDownloading ? ( + <Loader2 className="h-4 w-4 animate-spin mr-1" /> + ) : fileInfo.canPreview ? ( + <Eye className="h-4 w-4 mr-1" /> + ) : ( + <Download className="h-4 w-4 mr-1" /> + )} + {fileInfo.canPreview ? "보기" : "다운로드"} + </Button> */} + </div> + </TableCell> + </TableRow> + ) + }) + )} + </TableBody> + </Table> + )} + </ScrollArea> + <div className="flex items-center justify-between border-t pt-4 text-xs text-muted-foreground"> + {/* 하단 정보 */} + {attachments.length > 0 && !isLoading && ( + <div className="flex justify-between items-center"> + <span> + 총 {attachments.length}개 파일 + {attachments.some(a => a.fileSize) && + ` · 전체 크기: ${formatFileSize( + attachments.reduce((sum, a) => sum + (a.fileSize || 0), 0) + )}` + } + </span> + </div> + )} + + {/* 전체 다운로드 버튼 추가 */} + {attachments.length > 0 && !isLoading && ( + <Button + onClick={handleDownloadAll} + disabled={isDownloadingAll} + variant="outline" + size="sm" + > + {isDownloadingAll ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 다운로드 중... + </> + ) : ( + <> + <Download className="mr-2 h-4 w-4" /> + 전체 다운로드 + </> + )} + </Button> + )} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/rfq-items-dialog.tsx b/lib/rfq-last/vendor-response/rfq-items-dialog.tsx new file mode 100644 index 00000000..daa692e9 --- /dev/null +++ b/lib/rfq-last/vendor-response/rfq-items-dialog.tsx @@ -0,0 +1,354 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { Package, ExternalLink } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { Separator } from "@/components/ui/separator" +import { toast } from "sonner" +import { RfqsLastView } from "@/db/schema" +import { getRfqItemsAction } from "../service" + +// 품목 타입 +interface RfqItem { + id: number + rfqsLastId: number | null + rfqItem: string | null + prItem: string | null + prNo: string | null + materialCode: string | null + materialCategory: string | null + acc: string | null + materialDescription: string | null + size: string | null + deliveryDate: Date | null + quantity: number | null + uom: string | null + grossWeight: number | null + gwUom: string | null + specNo: string | null + specUrl: string | null + trackingNo: string | null + majorYn: boolean | null + remark: string | null + projectDef: string | null + projectSc: string | null + projectKl: string | null + projectLc: string | null + projectDl: string | null + // RFQ 관련 정보 + rfqCode: string | null + rfqType: string | null + rfqTitle: string | null + itemCode: string | null + itemName: string | null + projectCode: string | null + projectName: string | null +} + +interface ItemStatistics { + total: number + major: number + regular: number + totalQuantity: number + totalWeight: number +} + +interface RfqItemsDialogProps { + isOpen: boolean + onClose: () => void + rfqData: RfqsLastView +} + +export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps) { + const [items, setItems] = React.useState<RfqItem[]>([]) + const [statistics, setStatistics] = React.useState<ItemStatistics | null>(null) + const [isLoading, setIsLoading] = React.useState(false) + + // 품목 목록 로드 + React.useEffect(() => { + if (!isOpen || !rfqData.id) return + + const loadItems = async () => { + setIsLoading(true) + try { + const result = await getRfqItemsAction(rfqData.id) + + if (result.success) { + setItems(result.data) + setStatistics(result.statistics) + } else { + toast.error(result.error || "품목을 불러오는데 실패했습니다") + setItems([]) + setStatistics(null) + } + } catch (error) { + console.error("품목 로드 오류:", error) + toast.error("품목을 불러오는데 실패했습니다") + setItems([]) + setStatistics(null) + } finally { + setIsLoading(false) + } + } + + loadItems() + }, [isOpen, rfqData.id]) + + // 사양서 링크 열기 + const handleOpenSpec = (specUrl: string) => { + window.open(specUrl, '_blank', 'noopener,noreferrer') + } + + // 수량 포맷팅 + const formatQuantity = (quantity: number | null, uom: string | null) => { + if (!quantity) return "-" + return `${quantity.toLocaleString()}${uom ? ` ${uom}` : ""}` + } + + // 중량 포맷팅 + const formatWeight = (weight: number | null, uom: string | null) => { + if (!weight) return "-" + return `${weight.toLocaleString()} ${uom || "KG"}` + } + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-7xl h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>견적 품목 목록</DialogTitle> + <DialogDescription> + {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "품목 정보"} + </DialogDescription> + </DialogHeader> + + {/* 통계 정보 */} + {statistics && !isLoading && ( + <> +<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 py-3"> +<div className="text-center"> + <div className="text-2xl font-bold text-primary">{statistics.total}</div> + <div className="text-xs text-muted-foreground">전체 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-blue-600">{statistics.major}</div> + <div className="text-xs text-muted-foreground">주요 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-gray-600">{statistics.regular}</div> + <div className="text-xs text-muted-foreground">일반 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-green-600">{statistics.totalQuantity.toLocaleString()}</div> + <div className="text-xs text-muted-foreground">총 수량</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-orange-600">{statistics.totalWeight.toLocaleString()}</div> + <div className="text-xs text-muted-foreground">총 중량 (KG)</div> + </div> + </div> + <Separator /> + </> + )} + + <ScrollArea className="flex-1"> + {isLoading ? ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[60px]">구분</TableHead> + <TableHead className="w-[120px]">자재코드</TableHead> + <TableHead>자재명</TableHead> + <TableHead className="w-[100px]">수량</TableHead> + <TableHead className="w-[100px]">중량</TableHead> + <TableHead className="w-[100px]">납기일</TableHead> + <TableHead className="w-[100px]">PR번호</TableHead> + <TableHead className="w-[80px]">사양</TableHead> + <TableHead>비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {[...Array(3)].map((_, i) => ( + <TableRow key={i}> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) : items.length === 0 ? ( + <div className="text-center text-muted-foreground py-12"> + <Package className="h-12 w-12 mx-auto mb-4 text-muted-foreground" /> + <p>품목이 없습니다.</p> + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[60px]">구분</TableHead> + <TableHead className="w-[120px]">자재코드</TableHead> + <TableHead>자재명</TableHead> + <TableHead className="w-[100px]">수량</TableHead> + <TableHead className="w-[100px]">중량</TableHead> + <TableHead className="w-[100px]">납기일</TableHead> + <TableHead className="w-[100px]">PR번호</TableHead> + <TableHead className="w-[100px]">사양</TableHead> + <TableHead className="w-[100px]">프로젝트</TableHead> + <TableHead>비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {items.map((item, index) => ( + <TableRow key={item.id} className={item.majorYn ? "bg-blue-50 border-l-4 border-l-blue-500" : ""}> + <TableCell> + <div className="flex flex-col items-center gap-1"> + <span className="text-xs font-mono">#{index + 1}</span> + {item.majorYn && ( + <Badge variant="default" className="text-xs px-1 py-0"> + 주요 + </Badge> + )} + </div> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="font-mono text-sm font-medium">{item.materialCode || "-"}</span> + {item.acc && ( + <span className="text-xs text-muted-foreground font-mono"> + ACC: {item.acc} + </span> + )} + </div> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="text-sm font-medium" title={item.materialDescription || ""}> + {item.materialDescription || "-"} + </span> + {item.materialCategory && ( + <span className="text-xs text-muted-foreground"> + {item.materialCategory} + </span> + )} + {item.size && ( + <span className="text-xs text-muted-foreground"> + 크기: {item.size} + </span> + )} + </div> + </TableCell> + <TableCell> + <span className="text-sm font-medium"> + {formatQuantity(item.quantity, item.uom)} + </span> + </TableCell> + <TableCell> + <span className="text-sm"> + {formatWeight(item.grossWeight, item.gwUom)} + </span> + </TableCell> + <TableCell> + <span className="text-sm"> + {item.deliveryDate ? format(new Date(item.deliveryDate), "yyyy-MM-dd") : "-"} + </span> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="text-xs font-mono">{item.prNo || "-"}</span> + {item.prItem && item.prItem !== item.prNo && ( + <span className="text-xs text-muted-foreground font-mono"> + {item.prItem} + </span> + )} + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + {item.specNo && ( + <span className="text-xs font-mono">{item.specNo}</span> + )} + {item.specUrl && ( + <Button + variant="ghost" + size="sm" + className="h-5 w-5 p-0" + onClick={() => handleOpenSpec(item.specUrl!)} + title="사양서 열기" + > + <ExternalLink className="h-3 w-3" /> + </Button> + )} + {item.trackingNo && ( + <div className="text-xs text-muted-foreground"> + TRK: {item.trackingNo} + </div> + )} + </div> + </TableCell> + <TableCell> + <div className="text-xs"> + {[ + item.projectDef && `DEF: ${item.projectDef}`, + item.projectSc && `SC: ${item.projectSc}`, + item.projectKl && `KL: ${item.projectKl}`, + item.projectLc && `LC: ${item.projectLc}`, + item.projectDl && `DL: ${item.projectDl}` + ].filter(Boolean).join(" | ") || "-"} + </div> + </TableCell> + <TableCell> + <span className="text-xs" title={item.remark || ""}> + {item.remark ? (item.remark.length > 30 ? `${item.remark.slice(0, 30)}...` : item.remark) : "-"} + </span> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </ScrollArea> + + {/* 하단 통계 정보 */} + {statistics && !isLoading && ( + <div className="border-t pt-3 text-xs text-muted-foreground"> + <div className="flex justify-between items-center"> + <span> + 총 {statistics.total}개 품목 + (주요: {statistics.major}개, 일반: {statistics.regular}개) + </span> + <span> + 전체 수량: {statistics.totalQuantity.toLocaleString()} | + 전체 중량: {statistics.totalWeight.toLocaleString()} KG + </span> + </div> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/service.ts b/lib/rfq-last/vendor-response/service.ts new file mode 100644 index 00000000..7de3ae58 --- /dev/null +++ b/lib/rfq-last/vendor-response/service.ts @@ -0,0 +1,483 @@ +// getVendorQuotationsLast.ts +'use server' + +import { revalidatePath, unstable_cache } from "next/cache"; +import db from "@/db/db"; +import { and, or, eq, desc, asc, count, ilike, inArray } from "drizzle-orm"; +import { + rfqsLastView, + rfqLastDetails, + rfqLastVendorResponses, + type RfqsLastView +} from "@/db/schema"; +import { filterColumns } from "@/lib/filter-columns"; +import type { GetQuotationsLastSchema, UpdateParticipationSchema } from "@/lib/rfq-last/vendor-response/validations"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { getRfqAttachmentsAction } from "../service"; + + +export type VendorQuotationStatus = + | "미응답" // 초대받았지만 참여 여부 미결정 + | "불참" // 참여 거절 + | "작성중" // 참여 후 작성중 + | "제출완료" // 견적서 제출 완료 + | "수정요청" // 구매자가 수정 요청 + | "최종확정" // 최종 확정됨 + | "취소" // 취소됨 + +// 벤더 견적 뷰 타입 확장 +export interface VendorQuotationView extends RfqsLastView { + // 벤더 응답 정보 + responseStatus?: VendorQuotationStatus; + displayStatus?:string; + responseVersion?: number; + submittedAt?: Date; + totalAmount?: number; + vendorCurrency?: string; + + // 벤더별 조건 + vendorPaymentTerms?: string; + vendorIncoterms?: string; + vendorDeliveryDate?: Date; + + participationStatus: "미응답" | "참여" | "불참" | null + participationRepliedAt: Date | null + nonParticipationReason: string | null +} + +/** + * 벤더별 RFQ 목록 조회 + */ +export async function getVendorQuotationsLast( + input: GetQuotationsLastSchema, + vendorId: string +) { + return unstable_cache( + async () => { + try { + const numericVendorId = parseInt(vendorId); + if (isNaN(numericVendorId)) { + return { data: [], pageCount: 0 }; + } + + // 페이지네이션 설정 + const page = input.page || 1; + const perPage = input.perPage || 10; + const offset = (page - 1) * perPage; + + // 1. 먼저 벤더가 포함된 RFQ ID들 조회 + const vendorRfqIds = await db + .select({ rfqsLastId: rfqLastDetails.rfqsLastId }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.vendorsId, numericVendorId), + eq(rfqLastDetails.isLatest, true) + ) + ); + + + const rfqIds = vendorRfqIds.map(r => r.rfqsLastId).filter(id => id !== null); + + if (rfqIds.length === 0) { + return { data: [], pageCount: 0 }; + } + + // 2. 필터링 설정 + // advancedTable 모드로 where 절 구성 + const advancedWhere = filterColumns({ + table: rfqsLastView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 글로벌 검색 조건 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(rfqsLastView.rfqCode, s), + ilike(rfqsLastView.rfqTitle, s), + ilike(rfqsLastView.itemName, s), + ilike(rfqsLastView.projectName, s), + ilike(rfqsLastView.packageName, s), + ilike(rfqsLastView.status, s) + ); + } + + // RFQ ID 조건 (벤더가 포함된 RFQ만) + const rfqIdWhere = inArray(rfqsLastView.id, rfqIds); + + // 모든 조건 결합 + let whereConditions = [rfqIdWhere]; // 필수 조건 + if (advancedWhere) whereConditions.push(advancedWhere); + if (globalWhere) whereConditions.push(globalWhere); + + // 최종 조건 + const finalWhere = and(...whereConditions); + + // 3. 정렬 설정 + const orderBy = input.sort && input.sort.length > 0 + ? input.sort.map((item) => { + // @ts-ignore - 동적 속성 접근 + return item.desc ? desc(rfqsLastView[item.id]) : asc(rfqsLastView[item.id]); + }) + : [desc(rfqsLastView.updatedAt)]; + + // 4. 메인 쿼리 실행 + const quotations = await db + .select() + .from(rfqsLastView) + .where(finalWhere) + .orderBy(...orderBy) + .limit(perPage) + .offset(offset); + + // 5. 각 RFQ에 대한 벤더 응답 정보 조회 + const quotationsWithResponse = await Promise.all( + quotations.map(async (rfq) => { + // 벤더 응답 정보 조회 + const response = await db.query.rfqLastVendorResponses.findFirst({ + where: and( + eq(rfqLastVendorResponses.rfqsLastId, rfq.id), + eq(rfqLastVendorResponses.vendorId, numericVendorId), + eq(rfqLastVendorResponses.isLatest, true) + ), + columns: { + status: true, + responseVersion: true, + submittedAt: true, + totalAmount: true, + vendorCurrency: true, + vendorPaymentTermsCode: true, + vendorIncotermsCode: true, + vendorDeliveryDate: true, + participationStatus: true, + participationRepliedAt: true, + nonParticipationReason: true, + } + }); + + // 벤더 상세 정보 조회 + const detail = await db.query.rfqLastDetails.findFirst({ + where: and( + eq(rfqLastDetails.rfqsLastId, rfq.id), + eq(rfqLastDetails.vendorsId, numericVendorId), + eq(rfqLastDetails.isLatest, true) + ), + columns: { + id: true, // rfqLastDetailsId 필요 + emailSentAt: true, + emailStatus: true, + shortList: true, + } + }); + + // 표시할 상태 결정 (새로운 로직) + let displayStatus: string | null = null; + + if (response) { + // 응답 레코드가 있는 경우 + if (response.participationStatus === "불참") { + displayStatus = "불참"; + } else if (response.participationStatus === "참여") { + // 참여한 경우 실제 작업 상태 표시 + displayStatus = response.status || "작성중"; + } else { + // participationStatus가 없거나 "미응답"인 경우 + displayStatus = "미응답"; + } + } else { + // 응답 레코드가 없는 경우 + if (detail?.emailSentAt) { + displayStatus = "미응답"; // 초대는 받았지만 응답 안함 + } else { + displayStatus = null; // 아직 초대도 안됨 + } + } + + return { + ...rfq, + // 새로운 상태 체계 + displayStatus, // UI에서 표시할 통합 상태 + + // 참여 관련 정보 + participationStatus: response?.participationStatus || "미응답", + participationRepliedAt: response?.participationRepliedAt, + nonParticipationReason: response?.nonParticipationReason, + + // 견적 작업 상태 (참여한 경우에만 의미 있음) + responseStatus: response?.status, + responseVersion: response?.responseVersion, + submittedAt: response?.submittedAt, + totalAmount: response?.totalAmount, + vendorCurrency: response?.vendorCurrency, + vendorPaymentTerms: response?.vendorPaymentTermsCode, + vendorIncoterms: response?.vendorIncotermsCode, + vendorDeliveryDate: response?.vendorDeliveryDate, + + // 초대 관련 정보 + rfqLastDetailsId: detail?.id, // 참여 결정 시 필요 + emailSentAt: detail?.emailSentAt, + emailStatus: detail?.emailStatus, + shortList: detail?.shortList, + } as VendorQuotationView; + }) + ); + + // 6. 전체 개수 조회 + const { totalCount } = await db + .select({ totalCount: count() }) + .from(rfqsLastView) + .where(finalWhere) + .then(rows => rows[0]); + + // 페이지 수 계산 + const pageCount = Math.ceil(Number(totalCount) / perPage); + + + return { + data: quotationsWithResponse, + pageCount + }; + } catch (err) { + console.error("getVendorQuotationsLast 에러:", err); + return { data: [], pageCount: 0 }; + } + }, + [`vendor-quotations-last-${vendorId}-${JSON.stringify(input)}`], + { + revalidate: 60, + tags: [`vendor-quotations-last-${vendorId}`], + } + )(); +} + + + +export async function getQuotationStatusCountsLast(vendorId: string) { + return unstable_cache( + async () => { + try { + const numericVendorId = parseInt(vendorId); + if (isNaN(numericVendorId)) { + return { + "미응답": 0, + "불참": 0, + "작성중": 0, + "제출완료": 0, + "수정요청": 0, + "최종확정": 0, + "취소": 0, + } as Record<VendorQuotationStatus, number>; + } + + // 1. 벤더가 초대받은 전체 RFQ 조회 + const invitedRfqs = await db + .select({ + rfqsLastId: rfqLastDetails.rfqsLastId, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.vendorsId, numericVendorId), + eq(rfqLastDetails.isLatest, true) + ) + ); + + const invitedRfqIds = invitedRfqs.map(r => r.rfqsLastId); + const totalInvited = invitedRfqIds.length; + + // 초대받은 RFQ가 없으면 모두 0 반환 + if (totalInvited === 0) { + return { + "미응답": 0, + "불참": 0, + "작성중": 0, + "제출완료": 0, + "수정요청": 0, + "최종확정": 0, + "취소": 0, + } as Record<VendorQuotationStatus, number>; + } + + // 2. 벤더의 응답 상태 조회 + const vendorResponses = await db + .select({ + participationStatus: rfqLastVendorResponses.participationStatus, + status: rfqLastVendorResponses.status, + rfqsLastId: rfqLastVendorResponses.rfqsLastId, + }) + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.vendorId, numericVendorId), + eq(rfqLastVendorResponses.isLatest, true), + inArray(rfqLastVendorResponses.rfqsLastId, invitedRfqIds) + ) + ); + + // 3. 상태별 카운트 계산 + const result: Record<VendorQuotationStatus, number> = { + "미응답": 0, + "불참": 0, + "작성중": 0, + "제출완료": 0, + "수정요청": 0, + "최종확정": 0, + "취소": 0, + }; + + // 응답이 있는 RFQ ID 세트 + const respondedRfqIds = new Set(vendorResponses.map(r => r.rfqsLastId)); + + // 미응답 = 초대받았지만 응답 레코드가 없거나 participationStatus가 미응답인 경우 + result["미응답"] = totalInvited - respondedRfqIds.size; + + // 응답별 상태 카운트 + vendorResponses.forEach(response => { + // 불참한 경우 + if (response.participationStatus === "불참") { + result["불참"]++; + } + // 참여했지만 아직 participationStatus가 없는 경우 (기존 데이터 호환성) + else if (!response.participationStatus || response.participationStatus === "미응답") { + // 응답 레코드는 있지만 참여 여부 미결정 + result["미응답"]++; + } + // 참여한 경우 - status에 따라 분류 + else if (response.participationStatus === "참여") { + switch (response.status) { + case "대기중": + case "작성중": + result["작성중"]++; + break; + case "제출완료": + result["제출완료"]++; + break; + case "수정요청": + result["수정요청"]++; + break; + case "최종확정": + result["최종확정"]++; + break; + case "취소": + result["취소"]++; + break; + default: + // 기존 상태 호환성 처리 + if (response.status === "초대됨") { + result["미응답"]++; + } else if (response.status === "제출완료" || response.status === "Submitted") { + result["제출완료"]++; + } + break; + } + } + }); + + return result; + } catch (err) { + console.error("getQuotationStatusCountsLast 에러:", err); + return { + "미응답": 0, + "불참": 0, + "작성중": 0, + "제출완료": 0, + "수정요청": 0, + "최종확정": 0, + "취소": 0, + } as Record<VendorQuotationStatus, number>; + } + }, + [`quotation-status-counts-last-${vendorId}`], + { + revalidate: 60, + tags: [`quotation-status-last-${vendorId}`], + } + )(); +} + +export async function updateParticipationStatus( + input: UpdateParticipationSchema +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + const vendorId = session.user.companyId; + const { rfqId, rfqLastDetailsId, participationStatus, nonParticipationReason } = input + + // 기존 응답 레코드 찾기 또는 생성 + const existingResponse = await db + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, Number(vendorId)), + eq(rfqLastVendorResponses.rfqLastDetailsId, rfqLastDetailsId), + eq(rfqLastVendorResponses.isLatest, true) + ) + ) + .limit(1) + + + const now = new Date() + const userId = parseInt(session.user.id) + + if (existingResponse.length > 0) { + // 기존 레코드 업데이트 + await db + .update(rfqLastVendorResponses) + .set({ + participationStatus, + participationRepliedAt: now, + participationRepliedBy: userId, + nonParticipationReason: participationStatus === "불참" ? nonParticipationReason : null, + status: participationStatus === "참여" ? "작성중" : "대기중", + updatedAt: now, + updatedBy:userId, + }) + .where(eq(rfqLastVendorResponses.id, existingResponse[0].id)) + + } + + // revalidatePath("/vendor/quotations") + + return { + success: true, + message: participationStatus === "참여" + ? "견적 참여가 확정되었습니다." + : "견적 불참이 처리되었습니다." + } + } catch (error) { + console.error("참여 여부 업데이트 에러:", error) + return { + success: false, + message: "참여 여부 업데이트 중 오류가 발생했습니다." + } + } +} + + + +interface UpdateVendorContractRequirementsParams { + rfqId: number; + detailId: number; + contractRequirements: { + agreementYn: boolean; + ndaYn: boolean; + gtcType: "general" | "project" | "none"; + }; +} + +interface UpdateResult { + success: boolean; + error?: string; + data?: any; +} + diff --git a/lib/rfq-last/vendor-response/validations.ts b/lib/rfq-last/vendor-response/validations.ts new file mode 100644 index 00000000..033154c2 --- /dev/null +++ b/lib/rfq-last/vendor-response/validations.ts @@ -0,0 +1,42 @@ +import { createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum,parseAsBoolean +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { RfqsLastView } from "@/db/schema"; + + + +export const searchParamsVendorRfqCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<RfqsLastView>().withDefault([ + { id: "updatedAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + search: parseAsString.withDefault(""), + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), +}); + +export type GetQuotationsLastSchema = Awaited<ReturnType<typeof searchParamsVendorRfqCache.parse>>; + +// 참여 여부 업데이트 스키마 +export const updateParticipationSchema = z.object({ + rfqId: z.number(), + rfqLastDetailsId: z.number(), + participationStatus: z.enum(["참여", "불참"]), + nonParticipationReason: z.string().optional(), +}) + + +export type UpdateParticipationSchema = z.infer<typeof updateParticipationSchema>; diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx new file mode 100644 index 00000000..144c6c43 --- /dev/null +++ b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx @@ -0,0 +1,514 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { + FileText, + Edit, + Send, + Eye, + Clock, + CheckCircle, + AlertCircle, + XCircle, + Mail, + UserX +} from "lucide-react" +import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { useRouter } from "next/navigation" +import type { VendorQuotationView } from "./service" +import { ParticipationDialog } from "./participation-dialog" + +// 통합 상태 배지 컴포넌트 (displayStatus 사용) +function DisplayStatusBadge({ status }: { status: string | null }) { + if (!status) return null + + const config = { + "미응답": { variant: "secondary" as const, icon: Mail, label: "응답 대기" }, + "불참": { variant: "destructive" as const, icon: UserX, label: "불참" }, + "작성중": { variant: "outline" as const, icon: Edit, label: "작성중" }, + "제출완료": { variant: "default" as const, icon: CheckCircle, label: "제출완료" }, + "수정요청": { variant: "warning" as const, icon: AlertCircle, label: "수정요청" }, + "최종확정": { variant: "success" as const, icon: CheckCircle, label: "최종확정" }, + "취소": { variant: "destructive" as const, icon: XCircle, label: "취소" }, + } + + const { variant, icon: Icon, label } = config[status as keyof typeof config] || { + variant: "outline" as const, + icon: Clock, + label: status + } + + return ( + <Badge variant={variant} className="gap-1"> + <Icon className="h-3 w-3" /> + {label} + </Badge> + ) +} + +// RFQ 상태 배지 (기존 유지) +function RfqStatusBadge({ status }: { status: string }) { + const config: Record<string, { variant: "default" | "secondary" | "outline" | "destructive" | "warning" | "success" }> = { + "RFQ 생성": { variant: "outline" }, + "구매담당지정": { variant: "secondary" }, + "견적요청문서 확정": { variant: "secondary" }, + "TBE 완료": { variant: "warning" }, + "RFQ 발송": { variant: "default" }, + "견적접수": { variant: "success" }, + "최종업체선정": { variant: "success" }, + } + + const { variant } = config[status] || { variant: "outline" as const } + return <Badge variant={variant}>{status}</Badge> +} + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorQuotationView> | null>>; + router: NextRouter; + vendorId: number; // 추가: 벤더 ID 전달 +} + +export function getColumns({ + setRowAction, + router, + vendorId, // 추가 +}: GetColumnsProps): ColumnDef<VendorQuotationView>[] { + + // 체크박스 컬럼 (기존 유지) + const selectColumn: ColumnDef<VendorQuotationView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // 액션 컬럼 + const actionsColumn: ColumnDef<VendorQuotationView> = { + id: "actions", + header: "작업", + enableHiding: false, + cell: ({ row }) => { + const rfqId = row.original.id + const rfqCode = row.original.rfqCode + const displayStatus = row.original.displayStatus + const rfqLastDetailsId = row.original.rfqLastDetailsId + const [showParticipationDialog, setShowParticipationDialog] = React.useState(false) + + // displayStatus 기반으로 액션 결정 + switch (displayStatus) { + case "미응답": + return ( + <> + <Button + variant="default" + size="sm" + onClick={() => setShowParticipationDialog(true)} + className="h-8" + > + <Mail className="h-4 w-4 mr-1" /> + 참여 여부 결정 + </Button> + {showParticipationDialog && ( + <ParticipationDialog + rfqId={rfqId} + rfqCode={rfqCode} + rfqLastDetailsId={rfqLastDetailsId} + currentStatus={displayStatus} + onClose={() => setShowParticipationDialog(false)} + /> + )} + </> + ) + + case "불참": + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-sm text-muted-foreground">불참</span> + </TooltipTrigger> + {row.original.nonParticipationReason && ( + <TooltipContent> + <p className="max-w-xs"> + 불참 사유: {row.original.nonParticipationReason} + </p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + ) + + case "작성중": + case "대기중": + return ( + <Button + variant="default" + size="sm" + onClick={() => router.push(`/partners/rfq-last/${rfqId}`)} + className="h-8" + > + <Edit className="h-4 w-4 mr-1" /> + 견적서 작성 + </Button> + ) + + case "수정요청": + return ( + <Button + variant="warning" + size="sm" + onClick={() => router.push(`/partners/rfq-last/${rfqId}`)} + className="h-8" + > + <AlertCircle className="h-4 w-4 mr-1" /> + 견적서 수정 + </Button> + ) + + case "제출완료": + case "최종확정": + return ( + <Button + variant="outline" + size="sm" + onClick={() => router.push(`/partners/rfq-last/${rfqId}`)} + className="h-8" + > + <Eye className="h-4 w-4 mr-1" /> + 견적서 보기 + </Button> + ) + + case "취소": + return ( + <span className="text-sm text-muted-foreground">취소됨</span> + ) + + default: + return null + } + }, + size: 150, + } + + // 기본 컬럼들 + const columns: ColumnDef<VendorQuotationView>[] = [ + selectColumn, + { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 번호" /> + ), + cell: ({ row }) => { + const value = row.getValue("rfqCode") + return ( + <span className="font-mono text-sm font-medium"> + {value || "-"} + </span> + ) + }, + size: 140, + minSize: 120, + maxSize: 180, + enableResizing: true, + }, + { + accessorKey: "rfqType", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 유형" /> + ), + cell: ({ row }) => { + const rfqCode = row.original.rfqCode + const value = row.getValue("rfqType") + + // F로 시작하지 않으면 빈 값 반환 + if (!rfqCode?.startsWith('F')) { + return null + } + + const typeMap: Record<string, string> = { + "ITB": "ITB", + "RFQ": "RFQ", + "일반견적": "일반견적" + } + return typeMap[value as string] || value || "-" + }, + size: 100, + minSize: 80, + maxSize: 120, + enableResizing: true, + // F로 시작하지 않을 때 컬럼 숨기기 + enableHiding: true, + }, + { + accessorKey: "rfqTitle", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 제목" /> + ), + cell: ({ row }) => { + const rfqCode = row.original.rfqCode + const value = row.getValue("rfqTitle") + + // F로 시작하지 않으면 빈 값 반환 + if (!rfqCode?.startsWith('F')) { + return null + } + + return value || "-" + }, + minSize: 200, + maxSize: 400, + enableResizing: true, + enableHiding: true, + }, + { + accessorKey: "projectName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.projectCode} + </span> + <span className="max-w-[200px] truncate" title={row.original.projectName || ""}> + {row.original.projectName || "-"} + </span> + </div> + ), + minSize: 150, + maxSize: 300, + enableResizing: true, + }, + { + accessorKey: "itemName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="품목명" /> + ), + cell: ({ row }) => row.getValue("itemName") || "-", + minSize: 150, + maxSize: 300, + enableResizing: true, + }, + { + accessorKey: "packageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="패키지" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.packageNo} + </span> + <span className="max-w-[200px] truncate" title={row.original.packageName || ""}> + {row.original.packageName || "-"} + </span> + </div> + ), + minSize: 120, + maxSize: 250, + enableResizing: true, + }, + { + accessorKey: "MaterialGroup", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재그룹" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.majorItemMaterialCategory} + </span> + <span className="max-w-[200px] truncate" title={row.original.majorItemMaterialDescription || ""}> + {row.original.majorItemMaterialDescription || "-"} + </span> + </div> + ), + minSize: 120, + maxSize: 250, + enableResizing: true, + }, + + { + id: "rfqDocument", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 자료" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + onClick={() => setRowAction({ row, type: "attachment" })} + > + <FileText className="h-4 w-4" /> + </Button> + ), + size: 80, + }, + // 견적품목수 - 수정됨 + { + accessorKey: "prItemsCount", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적품목수" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="font-mono text-sm p-1 h-auto" + onClick={() => setRowAction({ row, type: "items" })} + > + {row.original.prItemsCount || 0} + </Button> + ), + size: 90, + }, + + { + accessorKey: "engPicName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계담당자" />, + cell: ({ row }) => row.original.engPicName || "-", + size: 100, + }, + + { + accessorKey: "picUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, + cell: ({ row }) => row.original.picUserName || row.original.picName || "-", + size: 100, + }, + + { + accessorKey: "submittedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="제출일" /> + ), + cell: ({ row }) => { + return row.original.submittedAt + ? formatDateTime(new Date(row.original.submittedAt)) + : "-" + }, + size: 150, + minSize: 120, + maxSize: 180, + enableResizing: true, + }, + { + accessorKey: "totalAmount", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="견적 금액" /> + ), + cell: ({ row }) => { + if (!row.original.totalAmount) return "-" + return formatCurrency( + row.original.totalAmount, + row.original.vendorCurrency || "USD" + ) + }, + size: 140, + minSize: 120, + maxSize: 180, + enableResizing: true, + }, + { + accessorKey: "displayStatus", // 변경: responseStatus → displayStatus + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => <DisplayStatusBadge status={row.original.displayStatus} />, + size: 120, + minSize: 100, + maxSize: 150, + enableResizing: true, + }, + { + accessorKey: "rfqSendDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="발송일" /> + ), + cell: ({ row }) => { + const value = row.getValue("rfqSendDate") + return value ? formatDateTime(new Date(value as string)) : "-" + }, + size: 150, + minSize: 120, + maxSize: 180, + enableResizing: true, + }, + { + accessorKey: "participationRepliedAt", // 추가: 참여 응답일 + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="참여 응답일" /> + ), + cell: ({ row }) => { + const value = row.getValue("participationRepliedAt") + return value ? formatDateTime(new Date(value as string)) : "-" + }, + size: 150, + minSize: 120, + maxSize: 180, + enableResizing: true, + enableHiding: true, // 선택적 표시 + }, + { + accessorKey: "dueDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="마감일" /> + ), + cell: ({ row }) => { + const value = row.getValue("dueDate") + const now = new Date() + const dueDate = value ? new Date(value as string) : null + const isOverdue = dueDate && dueDate < now + const isNearDeadline = dueDate && + (dueDate.getTime() - now.getTime()) < (24 * 60 * 60 * 1000) // 24시간 이내 + + return ( + <span className={ + isOverdue ? "text-red-600 font-semibold" : + isNearDeadline ? "text-orange-600 font-semibold" : + "" + }> + {dueDate ? formatDateTime(dueDate) : "-"} + </span> + ) + }, + size: 150, + minSize: 120, + maxSize: 180, + enableResizing: true, + }, + actionsColumn, + ] + + return columns +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx new file mode 100644 index 00000000..683a0318 --- /dev/null +++ b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx @@ -0,0 +1,171 @@ +// vendor-quotations-table-last.tsx +"use client" + +import * as React from "react" +import { + type DataTableAdvancedFilterField, + type DataTableFilterField, + type DataTableRowAction +} from "@/types/table" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useRouter } from "next/navigation" +import { getColumns } from "./vendor-quotations-table-columns" +import type { VendorQuotationView } from "./service" +import { RfqAttachmentsDialog } from "./rfq-attachments-dialog"; +import { RfqItemsDialog } from "./rfq-items-dialog"; + +interface VendorQuotationsTableLastProps { + promises: Promise<[{ data: VendorQuotationView[], pageCount: number }]> +} + +export function VendorQuotationsTableLast({ promises }: VendorQuotationsTableLastProps) { + const [{ data, pageCount }] = React.use(promises) + const router = useRouter() + + console.log(data,"VendorQuotationsTableLast") + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorQuotationView> | null>(null) + + // 테이블 컬럼 + const columns = React.useMemo(() => getColumns({ + setRowAction, + router, + }), [setRowAction, router]) + + // 응답 상태별 카운트 + const statusCounts = React.useMemo(() => { + return { + notResponded: data.filter(q => q.displayStatus === "미응답").length, + declined: data.filter(q => q.displayStatus === "불참").length, + drafting: data.filter(q => q.displayStatus === "작성중").length, + submitted: data.filter(q => q.displayStatus === "제출완료").length, + revisionRequested: data.filter(q => q.displayStatus === "수정요청").length, + confirmed: data.filter(q => q.displayStatus === "최종확정").length, + cancelled: data.filter(q => q.displayStatus === "취소").length, + } + }, [data]) + + // 필터 필드 + const filterFields: DataTableFilterField<VendorQuotationView>[] = [ + { + id: "displayStatus", + label: "상태", + options: [ + { label: "미응답", value: "미응답", count: statusCounts.notResponded }, + { label: "불참", value: "불참", count: statusCounts.declined }, + { label: "작성중", value: "작성중", count: statusCounts.drafting }, + { label: "제출완료", value: "제출완료", count: statusCounts.submitted }, + { label: "수정요청", value: "수정요청", count: statusCounts.revisionRequested }, + { label: "최종확정", value: "최종확정", count: statusCounts.confirmed }, + { label: "취소", value: "취소", count: statusCounts.cancelled }, + ] + }, + { + id: "rfqCode", + label: "RFQ 번호", + placeholder: "RFQ 번호 검색...", + }, + ] + + // 고급 필터 필드 + const advancedFilterFields: DataTableAdvancedFilterField<VendorQuotationView>[] = [ + { + id: "rfqCode", + label: "RFQ 번호", + type: "text", + }, + { + id: "rfqTitle", + label: "RFQ 제목", + type: "text", + }, + { + id: "projectName", + label: "프로젝트명", + type: "text", + }, + { + id: "displayStatus", + label: "상태", + options: [ + { label: "미응답", value: "미응답", count: statusCounts.notResponded }, + { label: "불참", value: "불참", count: statusCounts.declined }, + { label: "작성중", value: "작성중", count: statusCounts.drafting }, + { label: "제출완료", value: "제출완료", count: statusCounts.submitted }, + { label: "수정요청", value: "수정요청", count: statusCounts.revisionRequested }, + { label: "최종확정", value: "최종확정", count: statusCounts.confirmed }, + { label: "취소", value: "취소", count: statusCounts.cancelled }, + ] + }, + { + id: "dueDate", + label: "마감일", + type: "date", + }, + { + id: "submittedAt", + label: "제출일", + type: "date", + }, + ] + + // useDataTable 훅 사용 + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + enableColumnResizing: true, + columnResizeMode: 'onChange', + initialState: { + sorting: [{ id: "updatedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + defaultColumn: { + minSize: 50, + maxSize: 500, + }, + }) + + return ( + // <div className="w-full"> + <> + <div className="overflow-x-auto"> + <DataTable + table={table} + className="min-w-full" + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + /> + </DataTable> + </div> + {/* 다이얼로그들 */} + {rowAction?.type === "attachment" && ( + <RfqAttachmentsDialog + isOpen={true} + onClose={() => setRowAction(null)} + rfqData={rowAction.row.original} + /> + )} + + {rowAction?.type === "items" && ( + <RfqItemsDialog + isOpen={true} + onClose={() => setRowAction(null)} + rfqData={rowAction.row.original} + /> + )} + </> + // </div> + ) +}
\ No newline at end of file |
