summaryrefslogtreecommitdiff
path: root/lib/bidding
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding')
-rw-r--r--lib/bidding/pre-quote/service.ts5
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx123
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx51
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx68
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-dialog.tsx384
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx195
-rw-r--r--lib/bidding/vendor/partners-bidding-pre-quote.tsx49
7 files changed, 420 insertions, 455 deletions
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index bf7a4538..c34f6f9e 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -663,6 +663,8 @@ export async function getPrItemsForBidding(biddingId: number) {
// SPEC 문서 조회 (PR 아이템에 연결된 문서들)
export async function getSpecDocumentsForPrItem(prItemId: number) {
try {
+ console.log('getSpecDocumentsForPrItem called with prItemId:', prItemId)
+
const specDocs = await db
.select({
id: biddingDocuments.id,
@@ -678,10 +680,11 @@ export async function getSpecDocumentsForPrItem(prItemId: number) {
.where(
and(
eq(biddingDocuments.prItemId, prItemId),
- eq(biddingDocuments.documentType, 'specification')
+ eq(biddingDocuments.documentType, 'spec_document')
)
)
+ console.log('getSpecDocumentsForPrItem result:', specDocs)
return specDocs
} catch (error) {
console.error('Failed to get spec documents for PR item:', error)
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx
new file mode 100644
index 00000000..12bd2696
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx
@@ -0,0 +1,123 @@
+'use client'
+
+import * as React from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { PrItemsPricingTable } from '../../vendor/components/pr-items-pricing-table'
+import { getSavedPrItemQuotations } from '../service'
+
+interface PrItem {
+ id: number
+ itemNumber: string | null
+ prNumber: string | null
+ itemInfo: string | null
+ materialDescription: string | null
+ quantity: string | null
+ quantityUnit: string | null
+ currency: string | null
+ requestedDeliveryDate: string | null
+ hasSpecDocument: boolean | null
+}
+
+interface PrItemQuotation {
+ prItemId: number
+ bidUnitPrice: number
+ bidAmount: number
+ proposedDeliveryDate?: string
+ technicalSpecification?: string
+}
+
+interface BiddingPreQuoteItemDetailsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ biddingId: number
+ biddingCompanyId: number
+ companyName: string
+ prItems: PrItem[]
+ currency?: string
+}
+
+export function BiddingPreQuoteItemDetailsDialog({
+ open,
+ onOpenChange,
+ biddingId,
+ biddingCompanyId,
+ companyName,
+ prItems,
+ currency = 'KRW'
+}: BiddingPreQuoteItemDetailsDialogProps) {
+ const [prItemQuotations, setPrItemQuotations] = React.useState<PrItemQuotation[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 다이얼로그가 열릴 때 저장된 품목별 견적 데이터 로드
+ React.useEffect(() => {
+ if (open && biddingCompanyId) {
+ loadSavedQuotations()
+ }
+ }, [open, biddingCompanyId])
+
+ const loadSavedQuotations = async () => {
+ setIsLoading(true)
+ try {
+ console.log('Loading saved quotations for biddingCompanyId:', biddingCompanyId)
+ const savedQuotations = await getSavedPrItemQuotations(biddingCompanyId)
+ console.log('Loaded saved quotations:', savedQuotations)
+ setPrItemQuotations(savedQuotations)
+ } catch (error) {
+ console.error('Failed to load saved quotations:', error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleQuotationsChange = (quotations: PrItemQuotation[]) => {
+ // ReadOnly 모드이므로 변경사항을 저장하지 않음
+ console.log('Quotations changed (readonly):', quotations)
+ }
+
+ const handleTotalAmountChange = (total: number) => {
+ // ReadOnly 모드이므로 총 금액 변경을 처리하지 않음
+ console.log('Total amount changed (readonly):', total)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <span>품목별 견적 상세</span>
+ <span className="text-sm font-normal text-muted-foreground">
+ - {companyName}
+ </span>
+ </DialogTitle>
+ <DialogDescription>
+ 협력업체가 제출한 품목별 견적 상세 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-12">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
+ <p className="text-muted-foreground">견적 정보를 불러오는 중...</p>
+ </div>
+ </div>
+ ) : (
+ <PrItemsPricingTable
+ prItems={prItems}
+ initialQuotations={prItemQuotations}
+ currency={currency}
+ onQuotationsChange={handleQuotationsChange}
+ onTotalAmountChange={handleTotalAmountChange}
+ readOnly={true}
+ />
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
index 30cddbce..39fcb30f 100644
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
@@ -57,12 +57,16 @@ interface GetBiddingCompanyColumnsProps {
onEdit: (company: BiddingCompany) => void
onDelete: (company: BiddingCompany) => void
onInvite: (company: BiddingCompany) => void
+ onViewPriceAdjustment?: (company: BiddingCompany) => void
+ onViewItemDetails?: (company: BiddingCompany) => void
}
export function getBiddingPreQuoteVendorColumns({
onEdit,
onDelete,
- onInvite
+ onInvite,
+ onViewPriceAdjustment,
+ onViewItemDetails
}: GetBiddingCompanyColumnsProps): ColumnDef<BiddingCompany>[] {
return [
{
@@ -115,11 +119,24 @@ export function getBiddingPreQuoteVendorColumns({
{
accessorKey: 'preQuoteAmount',
header: '사전견적금액',
- cell: ({ row }) => (
- <div className="text-right font-mono">
- {row.original.preQuoteAmount ? Number(row.original.preQuoteAmount).toLocaleString() : '-'} KRW
- </div>
- ),
+ cell: ({ row }) => {
+ const hasAmount = row.original.preQuoteAmount && Number(row.original.preQuoteAmount) > 0
+ return (
+ <div className="text-right font-mono">
+ {hasAmount ? (
+ <button
+ onClick={() => onViewItemDetails?.(row.original)}
+ className="text-primary hover:text-primary/80 hover:underline cursor-pointer"
+ title="품목별 견적 상세 보기"
+ >
+ {Number(row.original.preQuoteAmount).toLocaleString()} KRW
+ </button>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )}
+ </div>
+ )
+ },
},
{
accessorKey: 'preQuoteSubmittedAt',
@@ -199,9 +216,21 @@ export function getBiddingPreQuoteVendorColumns({
const hasPriceAdjustment = row.original.priceAdjustmentResponse
if (hasPriceAdjustment === null) return <div className="text-sm">-</div>
return (
- <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}>
- {hasPriceAdjustment ? '적용' : '미적용'}
- </Badge>
+ <div className="flex items-center gap-2">
+ <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}>
+ {hasPriceAdjustment ? '적용' : '미적용'}
+ </Badge>
+ {hasPriceAdjustment && onViewPriceAdjustment && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => onViewPriceAdjustment(row.original)}
+ className="h-6 px-2 text-xs"
+ >
+ 상세
+ </Button>
+ )}
+ </div>
)
},
},
@@ -276,10 +305,10 @@ export function getBiddingPreQuoteVendorColumns({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>작업</DropdownMenuLabel>
- <DropdownMenuItem onClick={() => onEdit(company)}>
+ {/* <DropdownMenuItem onClick={() => onEdit(company)}>
<Edit className="mr-2 h-4 w-4" />
수정
- </DropdownMenuItem>
+ </DropdownMenuItem> */}
{company.invitationStatus === 'pending' && (
<DropdownMenuItem onClick={() => onInvite(company)}>
<UserPlus className="mr-2 h-4 w-4" />
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
index a9d12629..346bf9a6 100644
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
@@ -12,8 +12,12 @@ import { Bidding } from '@/db/schema'
import {
deleteBiddingCompany
} from '../service'
+import { getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
+import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
+import { BiddingPreQuoteItemDetailsDialog } from './bidding-pre-quote-item-details-dialog'
+import { getPrItemsForBidding } from '../service'
interface BiddingPreQuoteVendorTableContentProps {
biddingId: number
@@ -97,6 +101,11 @@ export function BiddingPreQuoteVendorTableContent({
const [isPending, startTransition] = useTransition()
const [selectedCompany, setSelectedCompany] = React.useState<BiddingCompany | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
+ const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
+ const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null)
+ const [isItemDetailsDialogOpen, setIsItemDetailsDialogOpen] = React.useState(false)
+ const [selectedCompanyForDetails, setSelectedCompanyForDetails] = React.useState<BiddingCompany | null>(null)
+ const [prItems, setPrItems] = React.useState<any[]>([])
const handleDelete = (company: BiddingCompany) => {
if (!confirm(`${company.companyName} 업체를 삭제하시겠습니까?`)) return
@@ -133,13 +142,51 @@ export function BiddingPreQuoteVendorTableContent({
})
}
+ const handleViewPriceAdjustment = async (company: BiddingCompany) => {
+ startTransition(async () => {
+ const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(company.id)
+ if (priceAdjustmentForm) {
+ setPriceAdjustmentData(priceAdjustmentForm)
+ setSelectedCompany(company)
+ setIsPriceAdjustmentDialogOpen(true)
+ } else {
+ toast({
+ title: '정보 없음',
+ description: '연동제 정보가 없습니다.',
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleViewItemDetails = async (company: BiddingCompany) => {
+ startTransition(async () => {
+ try {
+ // PR 아이템 정보 로드
+ const prItemsData = await getPrItemsForBidding(biddingId)
+ setPrItems(prItemsData)
+ setSelectedCompanyForDetails(company)
+ setIsItemDetailsDialogOpen(true)
+ } catch (error) {
+ console.error('Failed to load PR items:', error)
+ toast({
+ title: '오류',
+ description: '품목 정보를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
const columns = React.useMemo(
() => getBiddingPreQuoteVendorColumns({
onEdit: onEdit || handleEdit,
onDelete: onDelete || handleDelete,
- onInvite: handleInvite
+ onInvite: handleInvite,
+ onViewPriceAdjustment: handleViewPriceAdjustment,
+ onViewItemDetails: handleViewItemDetails
}),
- [onEdit, onDelete, handleEdit, handleDelete, handleInvite]
+ [onEdit, onDelete, handleEdit, handleDelete, handleInvite, handleViewPriceAdjustment, handleViewItemDetails]
)
const { table } = useDataTable({
@@ -184,6 +231,23 @@ export function BiddingPreQuoteVendorTableContent({
onOpenChange={setIsEditDialogOpen}
onSuccess={onRefresh}
/>
+
+ <PriceAdjustmentDialog
+ open={isPriceAdjustmentDialogOpen}
+ onOpenChange={setIsPriceAdjustmentDialogOpen}
+ data={priceAdjustmentData}
+ vendorName={selectedCompany?.companyName || ''}
+ />
+
+ <BiddingPreQuoteItemDetailsDialog
+ open={isItemDetailsDialogOpen}
+ onOpenChange={setIsItemDetailsDialogOpen}
+ biddingId={biddingId}
+ biddingCompanyId={selectedCompanyForDetails?.id || 0}
+ companyName={selectedCompanyForDetails?.companyName || ''}
+ prItems={prItems}
+ currency={bidding.currency || 'KRW'}
+ />
</>
)
}
diff --git a/lib/bidding/vendor/components/pr-items-pricing-dialog.tsx b/lib/bidding/vendor/components/pr-items-pricing-dialog.tsx
deleted file mode 100644
index ff0dfd9c..00000000
--- a/lib/bidding/vendor/components/pr-items-pricing-dialog.tsx
+++ /dev/null
@@ -1,384 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Textarea } from '@/components/ui/textarea'
-import { Badge } from '@/components/ui/badge'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
- DialogFooter,
-} from '@/components/ui/dialog'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table'
-import {
- Package,
- FileText,
- Download,
- Calculator,
- Save
-} from 'lucide-react'
-import { formatDate } from '@/lib/utils'
-import { downloadFile } from '@/lib/file-download'
-import { getSpecDocumentsForPrItem } from '../../pre-quote/service'
-import { useToast } from '@/hooks/use-toast'
-
-interface PrItem {
- id: number
- itemNumber: string | null
- prNumber: string | null
- itemInfo: string | null
- materialDescription: string | null
- quantity: string | null
- quantityUnit: string | null
- currency: string | null
- requestedDeliveryDate: string | null
- hasSpecDocument: boolean | null
-}
-
-interface PrItemQuotation {
- prItemId: number
- bidUnitPrice: number
- bidAmount: number
- proposedDeliveryDate?: string
- technicalSpecification?: string
-}
-
-interface SpecDocument {
- id: number
- fileName: string
- originalFileName: string
- fileSize: number | null
- filePath: string
- title: string | null
- description: string | null
- uploadedAt: string
-}
-
-interface PrItemsPricingDialogProps {
- prItems: PrItem[]
- initialQuotations?: PrItemQuotation[]
- currency?: string
- onSave: (quotations: PrItemQuotation[], totalAmount: number) => void
- readOnly?: boolean
- children: React.ReactNode
-}
-
-export function PrItemsPricingDialog({
- prItems,
- initialQuotations = [],
- currency = 'KRW',
- onSave,
- readOnly = false,
- children
-}: PrItemsPricingDialogProps) {
- const { toast } = useToast()
- const [open, setOpen] = React.useState(false)
- const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([])
- const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({})
- const [loadingSpecs, setLoadingSpecs] = React.useState<Record<number, boolean>>({})
-
- // 다이얼로그 열릴 때 초기 견적 데이터 설정
- React.useEffect(() => {
- if (open) {
- const initQuotations = prItems.map(item => {
- const existing = initialQuotations.find(q => q.prItemId === item.id)
- if (existing) {
- return existing
- }
- return {
- prItemId: item.id,
- bidUnitPrice: 0,
- bidAmount: 0,
- proposedDeliveryDate: '',
- technicalSpecification: ''
- }
- })
- setQuotations(initQuotations)
- }
- }, [open, prItems, initialQuotations])
-
- // SPEC 문서 로드
- const loadSpecDocuments = async (prItemId: number) => {
- if (loadingSpecs[prItemId]) return
-
- setLoadingSpecs(prev => ({ ...prev, [prItemId]: true }))
- try {
- const docs = await getSpecDocumentsForPrItem(prItemId)
- // Date를 string으로 변환
- const mappedDocs = docs.map(doc => ({
- ...doc,
- uploadedAt: doc.uploadedAt.toString()
- }))
- setSpecDocuments(prev => ({ ...prev, [prItemId]: mappedDocs }))
- } catch (error) {
- console.error('Failed to load spec documents:', error)
- } finally {
- setLoadingSpecs(prev => ({ ...prev, [prItemId]: false }))
- }
- }
-
- // 견적 데이터 업데이트
- const updateQuotation = (prItemId: number, field: keyof PrItemQuotation, value: any) => {
- const updatedQuotations = quotations.map(q => {
- if (q.prItemId === prItemId) {
- const updated = { ...q, [field]: value }
-
- // 단가가 변경되면 금액 자동 계산
- if (field === 'bidUnitPrice') {
- const prItem = prItems.find(item => item.id === prItemId)
- const quantity = parseFloat(prItem?.quantity || '1')
- updated.bidAmount = updated.bidUnitPrice * quantity
- }
-
- return updated
- }
- return q
- })
-
- setQuotations(updatedQuotations)
- }
-
- // 파일 다운로드
- const handleDownloadSpec = async (document: SpecDocument) => {
- try {
- await downloadFile(document.filePath, document.originalFileName, {
- showToast: true
- })
- } catch (error) {
- console.error('Failed to download spec document:', error)
- }
- }
-
- // 저장 처리
- const handleSave = () => {
- const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0)
- onSave(quotations, totalAmount)
- setOpen(false)
- toast({
- title: '저장 완료',
- description: '품목별 견적이 저장되었습니다.',
- })
- }
-
- // 통화 포맷팅
- const formatCurrency = (amount: number) => {
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: currency,
- }).format(amount)
- }
-
- // 총 금액 계산
- const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0)
-
- return (
- <Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- {children}
- </DialogTrigger>
- <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Package className="w-5 h-5" />
- 품목별 견적 작성
- </DialogTitle>
- <DialogDescription>
- 각 품목별로 견적 단가를 입력하여 총 사전견적 금액을 계산합니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4">
- <div className="overflow-x-auto">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>아이템번호</TableHead>
- <TableHead>PR번호</TableHead>
- <TableHead>품목정보</TableHead>
- <TableHead>자재내역</TableHead>
- <TableHead>수량</TableHead>
- <TableHead>단위</TableHead>
- <TableHead>견적단가</TableHead>
- <TableHead>견적금액</TableHead>
- <TableHead>납품예정일</TableHead>
- <TableHead>기술사양</TableHead>
- <TableHead>SPEC</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {prItems.map((item) => {
- const quotation = quotations.find(q => q.prItemId === item.id) || {
- prItemId: item.id,
- bidUnitPrice: 0,
- bidAmount: 0,
- proposedDeliveryDate: '',
- technicalSpecification: ''
- }
-
- return (
- <TableRow key={item.id}>
- <TableCell className="font-medium">
- {item.itemNumber || '-'}
- </TableCell>
- <TableCell>{item.prNumber || '-'}</TableCell>
- <TableCell>
- <div className="max-w-32 truncate" title={item.itemInfo || ''}>
- {item.itemInfo || '-'}
- </div>
- </TableCell>
- <TableCell>
- <div className="max-w-32 truncate" title={item.materialDescription || ''}>
- {item.materialDescription || '-'}
- </div>
- </TableCell>
- <TableCell className="text-right">
- {item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'}
- </TableCell>
- <TableCell>{item.quantityUnit || '-'}</TableCell>
- <TableCell>
- {readOnly ? (
- <span className="font-medium">
- {quotation.bidUnitPrice.toLocaleString()}
- </span>
- ) : (
- <Input
- type="number"
- value={quotation.bidUnitPrice}
- onChange={(e) => updateQuotation(
- item.id,
- 'bidUnitPrice',
- parseFloat(e.target.value) || 0
- )}
- className="w-32 text-right"
- placeholder="단가"
- />
- )}
- </TableCell>
- <TableCell>
- <div className="font-semibold text-primary">
- {formatCurrency(quotation.bidAmount)}
- </div>
- </TableCell>
- <TableCell>
- {readOnly ? (
- quotation.proposedDeliveryDate ?
- formatDate(quotation.proposedDeliveryDate, 'KR') : '-'
- ) : (
- <Input
- type="date"
- value={quotation.proposedDeliveryDate}
- onChange={(e) => updateQuotation(
- item.id,
- 'proposedDeliveryDate',
- e.target.value
- )}
- className="w-40"
- />
- )}
- </TableCell>
- <TableCell>
- {readOnly ? (
- <div className="max-w-32 truncate" title={quotation.technicalSpecification || ''}>
- {quotation.technicalSpecification || '-'}
- </div>
- ) : (
- <Textarea
- value={quotation.technicalSpecification}
- onChange={(e) => updateQuotation(
- item.id,
- 'technicalSpecification',
- e.target.value
- )}
- placeholder="기술사양 입력"
- className="w-48 min-h-[60px]"
- rows={2}
- />
- )}
- </TableCell>
- <TableCell>
- {item.hasSpecDocument ? (
- <div className="space-y-1">
- {!specDocuments[item.id] ? (
- <Button
- variant="outline"
- size="sm"
- onClick={() => loadSpecDocuments(item.id)}
- disabled={loadingSpecs[item.id]}
- >
- <FileText className="w-3 h-3 mr-1" />
- {loadingSpecs[item.id] ? '로딩...' : 'SPEC 보기'}
- </Button>
- ) : specDocuments[item.id].length > 0 ? (
- <div className="space-y-1">
- {specDocuments[item.id].map((doc) => (
- <Button
- key={doc.id}
- variant="outline"
- size="sm"
- onClick={() => handleDownloadSpec(doc)}
- className="block text-xs"
- >
- <Download className="w-3 h-3 mr-1" />
- {doc.originalFileName}
- </Button>
- ))}
- </div>
- ) : (
- <Badge variant="secondary">문서 없음</Badge>
- )}
- </div>
- ) : (
- <Badge variant="outline">SPEC 없음</Badge>
- )}
- </TableCell>
- </TableRow>
- )
- })}
- </TableBody>
- </Table>
- </div>
-
- {/* 총 금액 표시 */}
- <div className="flex justify-end border-t pt-4">
- <div className="bg-gray-50 rounded-lg p-4 min-w-80">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <Calculator className="w-5 h-5 text-primary" />
- <Label className="font-semibold text-lg">총 사전견적 금액</Label>
- </div>
- <div className="text-2xl font-bold text-primary">
- {formatCurrency(totalAmount)}
- </div>
- </div>
- </div>
- </div>
- </div>
-
- <DialogFooter>
- <Button variant="outline" onClick={() => setOpen(false)}>
- 취소
- </Button>
- {!readOnly && (
- <Button onClick={handleSave}>
- <Save className="w-4 h-4 mr-2" />
- 저장하기
- </Button>
- )}
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index 320ed6eb..01885f7a 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -22,8 +22,14 @@ import {
Calculator
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
-import { downloadFile } from '@/lib/file-download'
+import { downloadFile, formatFileSize, getFileInfo } from '@/lib/file-download'
import { getSpecDocumentsForPrItem } from '../../pre-quote/service'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
interface PrItem {
id: number
@@ -57,6 +63,93 @@ interface SpecDocument {
uploadedAt: string
}
+// 파일 다운로드 훅
+const useFileDownload = () => {
+ const [downloadingFiles, setDownloadingFiles] = React.useState<Set<string>>(new Set())
+
+ const handleDownload = async (filePath: string, fileName: string, options?: {
+ action?: 'download' | 'preview'
+ }) => {
+ const fileKey = `${filePath}_${fileName}`
+ if (downloadingFiles.has(fileKey)) return
+
+ setDownloadingFiles(prev => new Set(prev).add(fileKey))
+
+ try {
+ await downloadFile(filePath, fileName, {
+ action: options?.action || 'download',
+ showToast: true,
+ showSuccessToast: true,
+ onError: (error) => {
+ console.error("파일 다운로드 실패:", error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log("파일 다운로드 성공:", fileName, fileSize ? formatFileSize(fileSize) : '')
+ }
+ })
+ } catch (error) {
+ console.error("다운로드 처리 중 오류:", error)
+ } finally {
+ setDownloadingFiles(prev => {
+ const newSet = new Set(prev)
+ newSet.delete(fileKey)
+ return newSet
+ })
+ }
+ }
+
+ return { handleDownload, downloadingFiles }
+}
+
+// 파일 다운로드 링크 컴포넌트
+interface FileDownloadLinkProps {
+ filePath: string
+ fileName: string
+ fileSize?: number | null
+ title?: string | null
+ className?: string
+}
+
+const FileDownloadLink: React.FC<FileDownloadLinkProps> = ({
+ filePath,
+ fileName,
+ fileSize,
+ title,
+ className = ""
+}) => {
+ const { handleDownload, downloadingFiles } = useFileDownload()
+ const fileInfo = getFileInfo(fileName)
+ const fileKey = `${filePath}_${fileName}`
+ const isDownloading = downloadingFiles.has(fileKey)
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <button
+ onClick={() => handleDownload(filePath, fileName)}
+ disabled={isDownloading}
+ className={`inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 hover:underline disabled:opacity-50 disabled:cursor-not-allowed ${className}`}
+ >
+ <span className="text-xs">{fileInfo.icon}</span>
+ <span className="truncate max-w-[150px]">
+ {isDownloading ? "다운로드 중..." : (title || fileName)}
+ </span>
+ <Download className="h-3 w-3 opacity-60" />
+ </button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="text-xs">
+ <div className="font-medium">{fileName}</div>
+ {fileSize && <div className="text-muted-foreground">{formatFileSize(fileSize)}</div>}
+ <div className="text-muted-foreground">클릭하여 다운로드</div>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )
+}
+
interface PrItemsPricingTableProps {
prItems: PrItem[]
initialQuotations?: PrItemQuotation[]
@@ -76,9 +169,8 @@ export function PrItemsPricingTable({
}: PrItemsPricingTableProps) {
const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([])
const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({})
- const [loadingSpecs, setLoadingSpecs] = React.useState<Record<number, boolean>>({})
- // 초기 견적 데이터 설정
+ // 초기 견적 데이터 설정 및 SPEC 문서 로드
React.useEffect(() => {
const initQuotations = prItems.map(item => {
const existing = initialQuotations.find(q => q.prItemId === item.id)
@@ -94,27 +186,34 @@ export function PrItemsPricingTable({
}
})
setQuotations(initQuotations)
- }, [prItems, initialQuotations])
- // SPEC 문서 로드
- const loadSpecDocuments = async (prItemId: number) => {
- if (loadingSpecs[prItemId]) return
-
- setLoadingSpecs(prev => ({ ...prev, [prItemId]: true }))
- try {
- const docs = await getSpecDocumentsForPrItem(prItemId)
- // Date를 string으로 변환
- const mappedDocs = docs.map(doc => ({
- ...doc,
- uploadedAt: doc.uploadedAt.toString()
- }))
- setSpecDocuments(prev => ({ ...prev, [prItemId]: mappedDocs }))
- } catch (error) {
- console.error('Failed to load spec documents:', error)
- } finally {
- setLoadingSpecs(prev => ({ ...prev, [prItemId]: false }))
+ // SPEC 문서가 있는 모든 PR 아이템의 문서를 미리 로드
+ const loadAllSpecDocuments = async () => {
+ const itemsWithSpecs = prItems.filter(item => item.hasSpecDocument)
+ console.log('Loading spec documents for items:', itemsWithSpecs.map(item => ({ id: item.id, itemNumber: item.itemNumber })))
+
+ for (const item of itemsWithSpecs) {
+ try {
+ console.log('Loading spec documents for prItemId:', item.id)
+ const docs = await getSpecDocumentsForPrItem(item.id)
+ console.log('Loaded spec documents for item', item.id, ':', docs)
+
+ // Date를 string으로 변환
+ const mappedDocs = docs.map(doc => ({
+ ...doc,
+ uploadedAt: doc.uploadedAt.toString()
+ }))
+
+ setSpecDocuments(prev => ({ ...prev, [item.id]: mappedDocs }))
+ } catch (error) {
+ console.error('Failed to load spec documents for item', item.id, ':', error)
+ }
+ }
}
- }
+
+ loadAllSpecDocuments()
+ }, [prItems, initialQuotations])
+
// 견적 데이터 업데이트
const updateQuotation = (prItemId: number, field: keyof PrItemQuotation, value: any) => {
@@ -142,16 +241,6 @@ export function PrItemsPricingTable({
onTotalAmountChange(totalAmount)
}
- // 파일 다운로드
- const handleDownloadSpec = async (document: SpecDocument) => {
- try {
- await downloadFile(document.filePath, document.originalFileName, {
- showToast: true
- })
- } catch (error) {
- console.error('Failed to download spec document:', error)
- }
- }
// 통화 포맷팅
const formatCurrency = (amount: number) => {
@@ -187,7 +276,7 @@ export function PrItemsPricingTable({
<TableHead>견적단가</TableHead>
<TableHead>견적금액</TableHead>
<TableHead>납품예정일</TableHead>
- <TableHead>기술사양</TableHead>
+ {/* <TableHead>기술사양</TableHead> */}
<TableHead>SPEC</TableHead>
</TableRow>
</TableHeader>
@@ -262,7 +351,7 @@ export function PrItemsPricingTable({
/>
)}
</TableCell>
- <TableCell>
+ {/* <TableCell>
{readOnly ? (
<div className="max-w-32 truncate" title={quotation.technicalSpecification || ''}>
{quotation.technicalSpecification || '-'}
@@ -280,37 +369,29 @@ export function PrItemsPricingTable({
rows={2}
/>
)}
- </TableCell>
+ </TableCell> */}
<TableCell>
{item.hasSpecDocument ? (
<div className="space-y-1">
- {!specDocuments[item.id] ? (
- <Button
- variant="outline"
- size="sm"
- onClick={() => loadSpecDocuments(item.id)}
- disabled={loadingSpecs[item.id]}
- >
- <FileText className="w-3 h-3 mr-1" />
- {loadingSpecs[item.id] ? '로딩...' : 'SPEC 보기'}
- </Button>
- ) : specDocuments[item.id].length > 0 ? (
+ {specDocuments[item.id] && specDocuments[item.id].length > 0 ? (
<div className="space-y-1">
{specDocuments[item.id].map((doc) => (
- <Button
- key={doc.id}
- variant="outline"
- size="sm"
- onClick={() => handleDownloadSpec(doc)}
- className="block text-xs"
- >
- <Download className="w-3 h-3 mr-1" />
- {doc.originalFileName}
- </Button>
+ <div key={doc.id} className="text-xs">
+ <FileDownloadLink
+ filePath={doc.filePath}
+ fileName={doc.originalFileName}
+ fileSize={doc.fileSize}
+ title={doc.title}
+ className="text-xs"
+ />
+ </div>
))}
</div>
) : (
- <Badge variant="secondary">문서 없음</Badge>
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary">문서 없음</Badge>
+ <span className="text-xs text-muted-foreground">로딩 중...</span>
+ </div>
)}
</div>
) : (
diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
index d5ff3fd6..7b29b1a6 100644
--- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx
+++ b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
@@ -33,6 +33,7 @@ import {
savePreQuoteDraft
} from '../pre-quote/service'
import { getBiddingConditions } from '../service'
+import { getPriceAdjustmentFormByBiddingCompanyId } from '../detail/service'
import { PrItemsPricingTable } from './components/pr-items-pricing-table'
import { PreQuoteFileUpload } from './components/pre-quote-file-upload'
import {
@@ -166,6 +167,30 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
// 총 금액 계산
const calculatedTotal = savedQuotations.reduce((sum: number, item: any) => sum + item.bidAmount, 0)
setTotalAmount(calculatedTotal)
+
+ // 저장된 연동제 정보가 있으면 로드
+ if (result.priceAdjustmentResponse) {
+ const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(result.biddingCompanyId)
+ if (savedPriceAdjustmentForm) {
+ setPriceAdjustmentForm({
+ itemName: savedPriceAdjustmentForm.itemName || '',
+ adjustmentReflectionPoint: savedPriceAdjustmentForm.adjustmentReflectionPoint || '',
+ majorApplicableRawMaterial: savedPriceAdjustmentForm.majorApplicableRawMaterial || '',
+ adjustmentFormula: savedPriceAdjustmentForm.adjustmentFormula || '',
+ rawMaterialPriceIndex: savedPriceAdjustmentForm.rawMaterialPriceIndex || '',
+ referenceDate: savedPriceAdjustmentForm.referenceDate ? new Date(savedPriceAdjustmentForm.referenceDate).toISOString().split('T')[0] : '',
+ comparisonDate: savedPriceAdjustmentForm.comparisonDate ? new Date(savedPriceAdjustmentForm.comparisonDate).toISOString().split('T')[0] : '',
+ adjustmentRatio: savedPriceAdjustmentForm.adjustmentRatio?.toString() || '',
+ notes: savedPriceAdjustmentForm.notes || '',
+ adjustmentConditions: savedPriceAdjustmentForm.adjustmentConditions || '',
+ majorNonApplicableRawMaterial: savedPriceAdjustmentForm.majorNonApplicableRawMaterial || '',
+ adjustmentPeriod: savedPriceAdjustmentForm.adjustmentPeriod || '',
+ contractorWriter: savedPriceAdjustmentForm.contractorWriter || '',
+ adjustmentDate: savedPriceAdjustmentForm.adjustmentDate ? new Date(savedPriceAdjustmentForm.adjustmentDate).toISOString().split('T')[0] : '',
+ nonApplicableReason: savedPriceAdjustmentForm.nonApplicableReason || '',
+ })
+ }
+ }
}
// 기존 응답 데이터로 폼 초기화
@@ -347,6 +372,30 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
additionalProposals: updatedDetail.additionalProposals || '',
isAttendingMeeting: updatedDetail.isAttendingMeeting || false,
})
+
+ // 연동제 데이터도 다시 로드
+ if (updatedDetail.biddingCompanyId && updatedDetail.priceAdjustmentResponse) {
+ const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(updatedDetail.biddingCompanyId)
+ if (savedPriceAdjustmentForm) {
+ setPriceAdjustmentForm({
+ itemName: savedPriceAdjustmentForm.itemName || '',
+ adjustmentReflectionPoint: savedPriceAdjustmentForm.adjustmentReflectionPoint || '',
+ majorApplicableRawMaterial: savedPriceAdjustmentForm.majorApplicableRawMaterial || '',
+ adjustmentFormula: savedPriceAdjustmentForm.adjustmentFormula || '',
+ rawMaterialPriceIndex: savedPriceAdjustmentForm.rawMaterialPriceIndex || '',
+ referenceDate: savedPriceAdjustmentForm.referenceDate ? new Date(savedPriceAdjustmentForm.referenceDate).toISOString().split('T')[0] : '',
+ comparisonDate: savedPriceAdjustmentForm.comparisonDate ? new Date(savedPriceAdjustmentForm.comparisonDate).toISOString().split('T')[0] : '',
+ adjustmentRatio: savedPriceAdjustmentForm.adjustmentRatio?.toString() || '',
+ notes: savedPriceAdjustmentForm.notes || '',
+ adjustmentConditions: savedPriceAdjustmentForm.adjustmentConditions || '',
+ majorNonApplicableRawMaterial: savedPriceAdjustmentForm.majorNonApplicableRawMaterial || '',
+ adjustmentPeriod: savedPriceAdjustmentForm.adjustmentPeriod || '',
+ contractorWriter: savedPriceAdjustmentForm.contractorWriter || '',
+ adjustmentDate: savedPriceAdjustmentForm.adjustmentDate ? new Date(savedPriceAdjustmentForm.adjustmentDate).toISOString().split('T')[0] : '',
+ nonApplicableReason: savedPriceAdjustmentForm.nonApplicableReason || '',
+ })
+ }
+ }
}
} else {
toast({