summaryrefslogtreecommitdiff
path: root/lib/rfq-last
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-04 16:28:17 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-04 16:28:17 +0900
commit7bf90e71ee98abe2d65e18eaf20a449cd1bd097c (patch)
treed841864053b5e31e328f15f84098523cb260e805 /lib/rfq-last
parentb2436897b456066f3744890fd7da5ec0a2f05ea5 (diff)
(김준회) 파트너 RFQ 응답: RFQ PR 아이템 다이얼로그와 유사하게 변경, 기존 액션들은 유지
Diffstat (limited to 'lib/rfq-last')
-rw-r--r--lib/rfq-last/vendor-response/editor/quotation-items-table.tsx515
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