diff options
Diffstat (limited to 'lib')
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({ |
