diff options
Diffstat (limited to 'lib/rfq-last')
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/quotation-items-table.tsx | 515 |
1 files changed, 363 insertions, 152 deletions
diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx index 54866822..23ddc924 100644 --- a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx +++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx @@ -11,10 +11,15 @@ 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 { Separator } from "@/components/ui/separator" +import { ScrollArea } from "@/components/ui/scroll-area" +import { CalendarIcon, Eye, FileText, Download, ExternalLink } from "lucide-react" import { format } from "date-fns" import { cn, formatCurrency } from "@/lib/utils" import { useState, useEffect } from "react" +import { toast } from "sonner" +import { checkPosFileExists, getDownloadUrlByMaterialCode } from "@/lib/pos" +import { PosFileSelectionDialog } from "@/lib/pos/components/pos-file-selection-dialog" import { Dialog, DialogContent, @@ -23,17 +28,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog" interface QuotationItemsTableProps { prItems: any[] @@ -50,12 +44,35 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp const [showDetail, setShowDetail] = useState(false) const [showBulkDateDialog, setShowBulkDateDialog] = useState(false) const [bulkDeliveryDate, setBulkDeliveryDate] = useState<Date | undefined>(undefined) + + // POS 파일 관련 상태 + const [posDialogOpen, setPosDialogOpen] = useState(false) + const [selectedMaterialCode, setSelectedMaterialCode] = useState<string>("") + const [posFiles, setPosFiles] = useState<Array<{ + fileName: string + dcmtmId: string + projNo: string + posNo: string + posRevNo: string + fileSer: string + }>>([]) + const [loadingPosFiles, setLoadingPosFiles] = useState(false) + const [downloadingFileIndex, setDownloadingFileIndex] = useState<number | null>(null) const currency = watch("vendorCurrency") || "USD" const quotationItems = watch("quotationItems") console.log(prItems,"prItems") + // 통계 정보 계산 + const statistics = { + total: prItems.length, + regular: prItems.filter(item => !item.majorYn).length, + major: prItems.filter(item => item.majorYn).length, + totalQuantity: prItems.reduce((sum, item) => sum + (item.quantity || 0), 0), + totalWeight: prItems.reduce((sum, item) => sum + (item.grossWeight || 0), 0), + } + // PR 아이템 정보를 quotationItems에 초기화 useEffect(() => { if (prItems && prItems.length > 0) { @@ -90,19 +107,6 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp setValue(`quotationItems.${index}.uom`, prItem.uom) } } - - // 할인 적용 - 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) - setValue(`quotationItems.${index}.discountAmount`, discountAmount) - } - } // 일괄 납기일 적용 const applyBulkDeliveryDate = () => { @@ -122,6 +126,79 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp }) } + // 사양서 링크 열기 + const handleOpenSpec = (specUrl: string) => { + window.open(specUrl, '_blank', 'noopener,noreferrer') + } + + // POS 파일 목록 조회 및 다이얼로그 열기 + const handleOpenPosDialog = async (materialCode: string) => { + if (!materialCode) { + toast.error("자재코드가 없습니다") + return + } + + setLoadingPosFiles(true) + setSelectedMaterialCode(materialCode) + + try { + toast.loading(`POS 파일 목록 조회 중... (${materialCode})`, { id: `pos-check-${materialCode}` }) + + const result = await checkPosFileExists(materialCode) + + if (result.exists && result.files && result.files.length > 0) { + const detailResult = await getDownloadUrlByMaterialCode(materialCode) + + if (detailResult.success && detailResult.availableFiles) { + setPosFiles(detailResult.availableFiles) + setPosDialogOpen(true) + toast.success(`${result.fileCount}개의 POS 파일을 찾았습니다`, { id: `pos-check-${materialCode}` }) + } else { + toast.error('POS 파일 정보를 가져올 수 없습니다', { id: `pos-check-${materialCode}` }) + } + } else { + toast.error(result.error || 'POS 파일을 찾을 수 없습니다', { id: `pos-check-${materialCode}` }) + } + } catch (error) { + console.error("POS 파일 조회 오류:", error) + toast.error("POS 파일 조회에 실패했습니다", { id: `pos-check-${materialCode}` }) + } finally { + setLoadingPosFiles(false) + } + } + + // POS 파일 다운로드 실행 + const handleDownloadPosFile = async (fileIndex: number, fileName: string) => { + if (!selectedMaterialCode) return + + setDownloadingFileIndex(fileIndex) + + try { + toast.loading(`POS 파일 다운로드 준비 중...`, { id: `download-${fileIndex}` }) + + const downloadUrl = `/api/pos/download-on-demand?materialCode=${encodeURIComponent(selectedMaterialCode)}&fileIndex=${fileIndex}` + + toast.success(`POS 파일 다운로드 시작: ${fileName}`, { id: `download-${fileIndex}` }) + window.open(downloadUrl, '_blank', 'noopener,noreferrer') + + setTimeout(() => { + setDownloadingFileIndex(null) + }, 1000) + } catch (error) { + console.error("POS 파일 다운로드 오류:", error) + toast.error("POS 파일 다운로드에 실패했습니다", { id: `download-${fileIndex}` }) + setDownloadingFileIndex(null) + } + } + + // POS 다이얼로그 닫기 + const handleClosePosDialog = () => { + setPosDialogOpen(false) + setSelectedMaterialCode("") + setPosFiles([]) + setDownloadingFileIndex(null) + } + const totalAmount = quotationItems?.reduce( (sum: number, item: any) => sum + (item.totalPrice || 0), 0 ) || 0 @@ -397,139 +474,263 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp </div> </div> </div> + + {/* 통계 정보 */} + <div className="mt-4"> + <div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> + <div className="text-center p-3 border rounded-lg bg-muted/50"> + <div className="text-2xl font-bold text-primary">{statistics.total}</div> + <div className="text-xs text-muted-foreground">전체 품목</div> + </div> + <div className="text-center p-3 border rounded-lg bg-muted/50"> + <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 p-3 border rounded-lg bg-muted/50"> + <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 p-3 border rounded-lg bg-muted/50"> + <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 className="mt-4" /> + </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-[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} + <ScrollArea className="h-[600px]"> + <div className="overflow-x-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[60px]">No</TableHead> + <TableHead className="w-[100px]">PR No</TableHead> + <TableHead className="w-[80px]">PR 아이템</TableHead> + <TableHead className="min-w-[150px]">자재코드</TableHead> + <TableHead className="min-w-[200px]">자재명</TableHead> + <TableHead className="text-right w-[80px]">수량</TableHead> + <TableHead className="w-[60px]">단위</TableHead> + <TableHead className="text-right w-[80px]">중량</TableHead> + <TableHead className="w-[60px]">중량단위</TableHead> + <TableHead className="w-[150px]">단가</TableHead> + <TableHead className="text-right w-[150px]">총액</TableHead> + <TableHead className="w-[150px]">납기일</TableHead> + <TableHead className="w-[180px]">사양/POS</TableHead> + <TableHead className="w-[120px]">프로젝트</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-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> + {isMajor && ( + <Badge variant="default" className="text-xs px-1 py-0"> + 주요 + </Badge> + )} + </div> + </TableCell> + <TableCell className="font-mono text-xs"> + {prItem?.prNo || "-"} + </TableCell> + <TableCell className="font-mono text-xs"> + {prItem?.prItem || prItem?.rfqItem || "-"} + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="font-mono text-sm font-medium">{prItem?.materialCode || "-"}</span> + {prItem?.acc && ( + <span className="text-xs text-muted-foreground font-mono"> + ACC: {prItem.acc} + </span> + )} + </div> + </TableCell> + <TableCell> + <div className="flex flex-col max-w-[200px]"> + <p className="truncate text-sm font-medium" title={prItem?.materialDescription}> + {prItem?.materialDescription || "-"} </p> - )} - </div> - </TableCell> - <TableCell className="text-right"> - {prItem?.quantity} {prItem?.uom} - </TableCell> - <TableCell> - <div className="flex items-center gap-1"> - <Input - type="number" - min="0" - step="1" - {...register(`quotationItems.${index}.unitPrice`, { valueAsNumber: true })} - onChange={(e) => { - const value = Math.max(0, Math.floor(parseFloat(e.target.value) || 0)) - setValue(`quotationItems.${index}.unitPrice`, value) - calculateTotal(index) - }} - className="w-[120px]" - placeholder="0" - /> - <span className="text-xs text-muted-foreground"> - {currency} + {prItem?.materialCategory && ( + <span className="text-xs text-muted-foreground"> + {prItem.materialCategory} + </span> + )} + {prItem?.size && ( + <span className="text-xs text-muted-foreground"> + 크기: {prItem.size} + </span> + )} + </div> + </TableCell> + <TableCell className="text-right"> + <span className="text-sm font-medium"> + {prItem?.quantity ? prItem.quantity.toLocaleString() : "-"} + </span> + </TableCell> + <TableCell> + <span className="text-sm text-muted-foreground"> + {prItem?.uom || "-"} </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 + </TableCell> + <TableCell className="text-right"> + <span className="text-sm font-medium"> + {prItem?.grossWeight ? prItem.grossWeight.toLocaleString() : "-"} + </span> + </TableCell> + <TableCell> + <span className="text-sm text-muted-foreground"> + {prItem?.gwUom || "-"} + </span> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Input + type="number" + min="0" + step="1" + {...register(`quotationItems.${index}.unitPrice`, { valueAsNumber: true })} + onChange={(e) => { + const value = Math.max(0, Math.floor(parseFloat(e.target.value) || 0)) + setValue(`quotationItems.${index}.unitPrice`, value) + calculateTotal(index) + }} + className="w-[120px]" + placeholder="0" /> - </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> + <span className="text-xs text-muted-foreground"> + {currency} + </span> </div> - )} - </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> + </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> + <div className="flex flex-col gap-1"> + {/* 사양서 정보 */} + {(prItem?.specNo || prItem?.specUrl) && ( + <div className="flex items-center gap-1"> + {prItem.specNo && ( + <span className="text-xs font-mono">{prItem.specNo}</span> + )} + {prItem.specUrl && ( + <Button + type="button" + variant="ghost" + size="sm" + className="h-5 w-5 p-0" + onClick={() => handleOpenSpec(prItem.specUrl!)} + title="사양서 열기" + > + <ExternalLink className="h-3 w-3" /> + </Button> + )} + </div> + )} + + {/* POS 파일 다운로드 */} + {prItem?.materialCode && ( + <div className="flex items-center gap-1"> + <FileText className="h-3 w-3 text-green-500" /> + <Button + type="button" + variant="ghost" + size="sm" + className="h-5 p-1 text-xs text-green-600 hover:text-green-800" + onClick={() => handleOpenPosDialog(prItem.materialCode!)} + disabled={loadingPosFiles && selectedMaterialCode === prItem.materialCode} + title={`POS 파일 다운로드 (자재코드: ${prItem.materialCode})`} + > + <Download className="h-3 w-3 mr-1" /> + {loadingPosFiles && selectedMaterialCode === prItem.materialCode ? '조회중...' : 'POS'} + </Button> + </div> + )} + + {/* 트래킹 번호 */} + {prItem?.trackingNo && ( + <div className="text-xs text-muted-foreground"> + TRK: {prItem.trackingNo} + </div> + )} + </div> + </TableCell> + <TableCell> + <div className="text-xs"> + {[ + prItem?.projectDef && `${prItem.projectDef}`, + prItem?.projectSc && `SC: ${prItem.projectSc}`, + prItem?.projectKl && `KL: ${prItem.projectKl}`, + prItem?.projectLc && `LC: ${prItem.projectLc}`, + prItem?.projectDl && `DL: ${prItem.projectDl}` + ].filter(Boolean).join(" | ") || "-"} + </div> + </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> + </ScrollArea> {/* 총액 요약 */} <div className="mt-4 flex justify-end"> @@ -633,6 +834,16 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp </DialogFooter> </DialogContent> </Dialog> + + {/* POS 파일 선택 다이얼로그 */} + <PosFileSelectionDialog + isOpen={posDialogOpen} + onClose={handleClosePosDialog} + materialCode={selectedMaterialCode} + files={posFiles} + onDownload={handleDownloadPosFile} + downloadingIndex={downloadingFileIndex} + /> </Card> ) }
\ No newline at end of file |
