diff options
Diffstat (limited to 'lib/bidding/selection')
| -rw-r--r-- | lib/bidding/selection/actions.ts | 185 | ||||
| -rw-r--r-- | lib/bidding/selection/bidding-info-card.tsx | 14 | ||||
| -rw-r--r-- | lib/bidding/selection/bidding-item-table.tsx | 205 | ||||
| -rw-r--r-- | lib/bidding/selection/bidding-selection-detail-content.tsx | 11 | ||||
| -rw-r--r-- | lib/bidding/selection/biddings-selection-columns.tsx | 7 | ||||
| -rw-r--r-- | lib/bidding/selection/biddings-selection-table.tsx | 6 | ||||
| -rw-r--r-- | lib/bidding/selection/selection-result-form.tsx | 213 | ||||
| -rw-r--r-- | lib/bidding/selection/vendor-selection-table.tsx | 4 |
8 files changed, 528 insertions, 117 deletions
diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts index f19fbe6d..06dcbea1 100644 --- a/lib/bidding/selection/actions.ts +++ b/lib/bidding/selection/actions.ts @@ -131,6 +131,75 @@ export async function saveSelectionResult(data: SaveSelectionResultData) { } } +// 선정결과 조회 +export async function getSelectionResult(biddingId: number) { + try { + // 선정결과 조회 (selectedCompanyId가 null인 레코드) + const allResults = await db + .select() + .from(vendorSelectionResults) + .where(eq(vendorSelectionResults.biddingId, biddingId)) + + // @ts-ignore + const existingResult = allResults.filter((result: any) => result.selectedCompanyId === null).slice(0, 1) + + if (existingResult.length === 0) { + return { + success: true, + data: { + summary: '', + attachments: [] + } + } + } + + const result = existingResult[0] + + // 첨부파일 조회 + const documents = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + mimeType: biddingDocuments.mimeType, + filePath: biddingDocuments.filePath, + uploadedAt: biddingDocuments.uploadedAt + }) + .from(biddingDocuments) + .where(and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'selection_result') + )) + + return { + success: true, + data: { + summary: result.evaluationSummary || '', + attachments: documents.map(doc => ({ + id: doc.id, + fileName: doc.fileName || doc.originalFileName || '', + originalFileName: doc.originalFileName || '', + fileSize: doc.fileSize || 0, + mimeType: doc.mimeType || '', + filePath: doc.filePath || '', + uploadedAt: doc.uploadedAt + })) + } + } + } catch (error) { + console.error('Failed to get selection result:', error) + return { + success: false, + error: '선정결과 조회 중 오류가 발생했습니다.', + data: { + summary: '', + attachments: [] + } + } + } +} + // 견적 히스토리 조회 export async function getQuotationHistory(biddingId: number, vendorId: number) { try { @@ -168,12 +237,14 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) { .where(eq(biddings.originalBiddingNumber, baseNumber)) .orderBy(biddings.createdAt) - // 각 bidding에 대한 벤더의 견적 정보 조회 + // 각 bidding에 대한 벤더의 견적 정보 및 상세 아이템 조회 const historyPromises = relatedBiddings.map(async (bidding) => { + // 1. 견적 헤더 정보 조회 (ID 포함) const biddingCompanyData = await db .select({ + id: biddingCompanies.id, finalQuoteAmount: biddingCompanies.finalQuoteAmount, - responseSubmittedAt: biddingCompanies.responseSubmittedAt, + responseSubmittedAt: biddingCompanies.finalQuoteSubmittedAt, isFinalSubmission: biddingCompanies.isFinalSubmission }) .from(biddingCompanies) @@ -187,84 +258,72 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) { return null } - return { - biddingId: bidding.id, - biddingNumber: bidding.biddingNumber, - finalQuoteAmount: biddingCompanyData[0].finalQuoteAmount, - responseSubmittedAt: biddingCompanyData[0].responseSubmittedAt, - isFinalSubmission: biddingCompanyData[0].isFinalSubmission, - targetPrice: bidding.targetPrice, - currency: bidding.currency - } - }) - - const historyData = (await Promise.all(historyPromises)).filter(Boolean) - - // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등) - const sortedHistory = historyData.sort((a, b) => { - const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0 - const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0 - return aSuffix - bSuffix - }) - - // PR 항목 정보 조회 (현재 bidding 기준) - const prItems = await db - .select({ - id: prItemsForBidding.id, - itemNumber: prItemsForBidding.itemNumber, - itemInfo: prItemsForBidding.itemInfo, - quantity: prItemsForBidding.quantity, - quantityUnit: prItemsForBidding.quantityUnit, - requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate - }) - .from(prItemsForBidding) - .where(eq(prItemsForBidding.biddingId, biddingId)) - - // 각 히스토리 항목에 대한 PR 아이템 견적 조회 - const history = await Promise.all(sortedHistory.map(async (item, index) => { - // 각 bidding에 대한 PR 아이템 견적 조회 + // 2. 아이템별 견적 및 상세 정보 조회 (Join 사용) const prItemBids = await db .select({ - prItemId: companyPrItemBids.prItemId, + // 견적 정보 bidUnitPrice: companyPrItemBids.bidUnitPrice, bidAmount: companyPrItemBids.bidAmount, - proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, + // 아이템 상세 정보 + prItemId: prItemsForBidding.id, + itemNumber: prItemsForBidding.itemNumber, + itemInfo: prItemsForBidding.itemInfo, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate }) .from(companyPrItemBids) - .where(and( - eq(companyPrItemBids.biddingId, item!.biddingId), - eq(companyPrItemBids.companyId, vendorId) - )) + .innerJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id)) + .where(eq(companyPrItemBids.biddingCompanyId, biddingCompanyData[0].id)) - const targetPrice = item!.targetPrice ? parseFloat(item!.targetPrice.toString()) : null - const totalAmount = parseFloat(item!.finalQuoteAmount.toString()) + // 아이템 매핑 + const items = prItemBids.map(bid => ({ + itemCode: bid.itemNumber || `ITEM${bid.prItemId}`, + itemName: bid.itemInfo || '품목 정보 없음', + quantity: bid.quantity ? parseFloat(bid.quantity.toString()) : 0, + unit: bid.quantityUnit || 'EA', + unitPrice: bid.bidUnitPrice ? parseFloat(bid.bidUnitPrice.toString()) : 0, + totalPrice: bid.bidAmount ? parseFloat(bid.bidAmount.toString()) : 0, + deliveryDate: bid.proposedDeliveryDate + ? new Date(bid.proposedDeliveryDate) + : bid.requestedDeliveryDate + ? new Date(bid.requestedDeliveryDate) + : new Date() + })) + + const targetPrice = bidding.targetPrice ? parseFloat(bidding.targetPrice.toString()) : null + const totalAmount = parseFloat(biddingCompanyData[0].finalQuoteAmount.toString()) const vsTargetPrice = targetPrice && targetPrice > 0 ? ((totalAmount - targetPrice) / targetPrice) * 100 : 0 - const items = prItemBids.map(bid => { - const prItem = prItems.find(p => p.id === bid.prItemId) - return { - itemCode: prItem?.itemNumber || `ITEM${bid.prItemId}`, - itemName: prItem?.itemInfo || '품목 정보 없음', - quantity: prItem?.quantity || 0, - unit: prItem?.quantityUnit || 'EA', - unitPrice: parseFloat(bid.bidUnitPrice.toString()), - totalPrice: parseFloat(bid.bidAmount.toString()), - deliveryDate: bid.proposedDeliveryDate ? new Date(bid.proposedDeliveryDate) : prItem?.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : new Date() - } - }) - return { - id: item!.biddingId, - round: index + 1, // 1차, 2차, 3차... - submittedAt: new Date(item!.responseSubmittedAt), + biddingId: bidding.id, + biddingNumber: bidding.biddingNumber, + submittedAt: new Date(biddingCompanyData[0].responseSubmittedAt), totalAmount, - currency: item!.currency || 'KRW', + currency: bidding.currency || 'KRW', vsTargetPrice: parseFloat(vsTargetPrice.toFixed(2)), items } + }) + + const historyData = (await Promise.all(historyPromises)).filter(Boolean) + + // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등) + const sortedHistory = historyData.sort((a, b) => { + const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0 + const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0 + return aSuffix - bSuffix + }) + + // 회차 정보 추가 + const history = sortedHistory.map((item, index) => ({ + id: item!.biddingId, + round: index + 1, // 1차, 2차, 3차... + ...item! })) return { diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx index 8864e7db..5904bf65 100644 --- a/lib/bidding/selection/bidding-info-card.tsx +++ b/lib/bidding/selection/bidding-info-card.tsx @@ -5,6 +5,18 @@ import { Badge } from '@/components/ui/badge' // import { formatDate } from '@/lib/utils' import { biddingStatusLabels, contractTypeLabels } from '@/db/schema' +// 입찰유형 라벨 맵 추가 +const biddingTypeLabels: Record<string, string> = { + equipment: '기자재', + construction: '공사', + service: '용역', + lease: '임차', + transport: '운송', + waste: '폐기물', + sale: '매각', + other: '기타(직접입력)', +} + interface BiddingInfoCardProps { bidding: Bidding } @@ -56,7 +68,7 @@ export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) { 입찰유형 </label> <div className="text-sm font-medium"> - {bidding.isPublic ? '공개입찰' : '비공개입찰'} + {biddingTypeLabels[bidding.biddingType as keyof typeof biddingTypeLabels] || bidding.biddingType || '-'} </div> </div> diff --git a/lib/bidding/selection/bidding-item-table.tsx b/lib/bidding/selection/bidding-item-table.tsx new file mode 100644 index 00000000..aa2b34ec --- /dev/null +++ b/lib/bidding/selection/bidding-item-table.tsx @@ -0,0 +1,205 @@ +'use client' + +import * as React from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { getBiddingSelectionItemsAndPrices } from '@/lib/bidding/service' +import { formatNumber } from '@/lib/utils' +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' + +interface BiddingItemTableProps { + biddingId: number +} + +export function BiddingItemTable({ biddingId }: BiddingItemTableProps) { + const [data, setData] = React.useState<{ + prItems: any[] + vendorPrices: any[] + }>({ prItems: [], vendorPrices: [] }) + const [loading, setLoading] = React.useState(true) + + React.useEffect(() => { + let isMounted = true + + const loadData = async () => { + try { + setLoading(true) + const { prItems, vendorPrices } = await getBiddingSelectionItemsAndPrices(biddingId) + + if (isMounted) { + console.log('prItems', prItems) + console.log('vendorPrices', vendorPrices) + setData({ prItems, vendorPrices }) + } + } catch (error) { + console.error('Failed to load bidding items:', error) + } finally { + if (isMounted) { + setLoading(false) + } + } + } + + loadData() + + return () => { + isMounted = false + } + }, [biddingId]) + + // Memoize calculations + const totals = React.useMemo(() => { + const { prItems } = data + return { + quantity: prItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0), + weight: prItems.reduce((sum, item) => sum + Number(item.totalWeight || 0), 0), + targetAmount: prItems.reduce((sum, item) => sum + Number(item.targetAmount || 0), 0) + } + }, [data.prItems]) + + const vendorTotals = React.useMemo(() => { + const { vendorPrices } = data + return vendorPrices.map(vendor => { + const total = vendor.itemPrices.reduce((sum: number, item: any) => sum + Number(item.amount || 0), 0) + return { + companyId: vendor.companyId, + totalAmount: total + } + }) + }, [data.vendorPrices]) + + if (loading) { + return ( + <Card> + <CardHeader> + <CardTitle>응찰품목</CardTitle> + </CardHeader> + <CardContent> + <div className="flex items-center justify-center py-8"> + <div className="text-sm text-muted-foreground">로딩 중...</div> + </div> + </CardContent> + </Card> + ) + } + + const { prItems, vendorPrices } = data + + + return ( + <Card> + <CardHeader> + <CardTitle>응찰품목</CardTitle> + </CardHeader> + <CardContent> + <ScrollArea className="w-full whitespace-nowrap rounded-md border"> + <div className="w-max min-w-full"> + <table className="w-full caption-bottom text-sm"> + <thead className="[&_tr]:border-b"> + {/* Header Row 1: Base Info + Vendor Groups */} + <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재번호</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역상세</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>구매단위</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>수량</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>단위</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>총중량</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>중량단위</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정단가</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정액</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>통화</th> + + {vendorPrices.map((vendor) => ( + <th key={vendor.companyId} colSpan={4} className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r bg-muted/20"> + {vendor.companyName} + </th> + ))} + </tr> + {/* Header Row 2: Vendor Sub-columns */} + <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> + {vendorPrices.map((vendor) => ( + <React.Fragment key={vendor.companyId}> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">단가</th> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">총액</th> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">통화</th> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">내정액(%)</th> + </React.Fragment> + ))} + </tr> + </thead> + <tbody className="[&_tr:last-child]:border-0"> + {/* Summary Row */} + <tr className="border-b transition-colors hover:bg-muted/50 bg-muted/30 font-semibold"> + <td className="p-4 align-middle text-center border-r" colSpan={4}>합계</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(totals.quantity)}</td> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(totals.weight)}</td> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(totals.targetAmount)}</td> + <td className="p-4 align-middle text-center border-r">KRW</td> + + {vendorPrices.map((vendor) => { + const vTotal = vendorTotals.find(t => t.companyId === vendor.companyId)?.totalAmount || 0 + const ratio = totals.targetAmount > 0 ? (vTotal / totals.targetAmount) * 100 : 0 + return ( + <React.Fragment key={vendor.companyId}> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(vTotal)}</td> + <td className="p-4 align-middle text-center border-r">{vendor.currency}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(ratio, 0)}%</td> + </React.Fragment> + ) + })} + </tr> + + {/* Data Rows */} + {prItems.map((item) => ( + <tr key={item.id} className="border-b transition-colors hover:bg-muted/50"> + <td className="p-4 align-middle border-r">{item.materialNumber}</td> + <td className="p-4 align-middle border-r min-w-[150px]">{item.materialInfo}</td> + <td className="p-4 align-middle border-r min-w-[150px]">{item.specification}</td> + <td className="p-4 align-middle text-center border-r">{item.purchaseUnit}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.quantity)}</td> + <td className="p-4 align-middle text-center border-r">{item.quantityUnit}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.totalWeight)}</td> + <td className="p-4 align-middle text-center border-r">{item.weightUnit}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetUnitPrice)}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetAmount)}</td> + <td className="p-4 align-middle text-center border-r">{item.currency}</td> + + {vendorPrices.map((vendor) => { + const bidItem = vendor.itemPrices.find((p: any) => p.prItemId === item.id) + const bidAmount = bidItem ? bidItem.amount : 0 + const targetAmt = Number(item.targetAmount || 0) + const ratio = targetAmt > 0 && bidAmount > 0 ? (bidAmount / targetAmt) * 100 : 0 + + return ( + <React.Fragment key={vendor.companyId}> + <td className="p-4 align-middle text-right border-r bg-muted/5"> + {bidItem ? formatNumber(bidItem.unitPrice) : '-'} + </td> + <td className="p-4 align-middle text-right border-r bg-muted/5"> + {bidItem ? formatNumber(bidItem.amount) : '-'} + </td> + <td className="p-4 align-middle text-center border-r bg-muted/5"> + {vendor.currency} + </td> + <td className="p-4 align-middle text-right border-r bg-muted/5"> + {bidItem && ratio > 0 ? `${formatNumber(ratio, 0)}%` : '-'} + </td> + </React.Fragment> + ) + })} + </tr> + ))} + </tbody> + </table> + </div> + <ScrollBar orientation="horizontal" /> + </ScrollArea> + </CardContent> + </Card> + ) +} + diff --git a/lib/bidding/selection/bidding-selection-detail-content.tsx b/lib/bidding/selection/bidding-selection-detail-content.tsx index 45d5d402..887498dc 100644 --- a/lib/bidding/selection/bidding-selection-detail-content.tsx +++ b/lib/bidding/selection/bidding-selection-detail-content.tsx @@ -5,6 +5,7 @@ import { Bidding } from '@/db/schema' import { BiddingInfoCard } from './bidding-info-card' import { SelectionResultForm } from './selection-result-form' import { VendorSelectionTable } from './vendor-selection-table' +import { BiddingItemTable } from './bidding-item-table' interface BiddingSelectionDetailContentProps { biddingId: number @@ -17,6 +18,9 @@ export function BiddingSelectionDetailContent({ }: BiddingSelectionDetailContentProps) { const [refreshKey, setRefreshKey] = React.useState(0) + // 입찰평가중 상태가 아니면 읽기 전용 + const isReadOnly = bidding.status !== 'evaluation_of_bidding' + const handleRefresh = React.useCallback(() => { setRefreshKey(prev => prev + 1) }, []) @@ -27,7 +31,7 @@ export function BiddingSelectionDetailContent({ <BiddingInfoCard bidding={bidding} /> {/* 선정결과 폼 */} - <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} /> + <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} readOnly={isReadOnly} /> {/* 업체선정 테이블 */} <VendorSelectionTable @@ -35,7 +39,12 @@ export function BiddingSelectionDetailContent({ biddingId={biddingId} bidding={bidding} onRefresh={handleRefresh} + readOnly={isReadOnly} /> + + {/* 응찰품목 테이블 */} + <BiddingItemTable biddingId={biddingId} /> + </div> ) } diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx index 87c489e3..030fc05b 100644 --- a/lib/bidding/selection/biddings-selection-columns.tsx +++ b/lib/bidding/selection/biddings-selection-columns.tsx @@ -177,14 +177,13 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps): const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // 비교로직만 유지, 색상표기/마감뱃지 제거
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-xs">
<div>
- {formatKst(startObj)} ~ {formatKst(endObj)}
+ {formatValue(startObj)} ~ {formatValue(endObj)}
</div>
</div>
)
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx index c3990e7b..41225531 100644 --- a/lib/bidding/selection/biddings-selection-table.tsx +++ b/lib/bidding/selection/biddings-selection-table.tsx @@ -84,13 +84,13 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- // 입찰평가중일때만 상세보기 가능
- if (rowAction.row.original.status === 'evaluation_of_bidding') {
+ // 입찰평가중, 업체선정, 차수증가, 재입찰 상태일 때 상세보기 가능
+ if (['evaluation_of_bidding', 'vendor_selected', 'round_increase', 'rebidding'].includes(rowAction.row.original.status)) {
router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
} else {
toast({
title: '접근 제한',
- description: '입찰평가중이 아닙니다.',
+ description: '상세보기가 불가능한 상태입니다.',
variant: 'destructive',
})
}
diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx index 54687cc9..af6b8d43 100644 --- a/lib/bidding/selection/selection-result-form.tsx +++ b/lib/bidding/selection/selection-result-form.tsx @@ -9,8 +9,8 @@ import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { useToast } from '@/hooks/use-toast' -import { saveSelectionResult } from './actions' -import { Loader2, Save, FileText } from 'lucide-react' +import { saveSelectionResult, getSelectionResult } from './actions' +import { Loader2, Save, FileText, Download, X } from 'lucide-react' import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, DropzoneInput } from '@/components/ui/dropzone' const selectionResultSchema = z.object({ @@ -22,12 +22,25 @@ type SelectionResultFormData = z.infer<typeof selectionResultSchema> interface SelectionResultFormProps { biddingId: number onSuccess: () => void + readOnly?: boolean } -export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) { +interface AttachmentInfo { + id: number + fileName: string + originalFileName: string + fileSize: number + mimeType: string + filePath: string + uploadedAt: Date | null +} + +export function SelectionResultForm({ biddingId, onSuccess, readOnly = false }: SelectionResultFormProps) { const { toast } = useToast() const [isSubmitting, setIsSubmitting] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(true) const [attachmentFiles, setAttachmentFiles] = React.useState<File[]>([]) + const [existingAttachments, setExistingAttachments] = React.useState<AttachmentInfo[]>([]) const form = useForm<SelectionResultFormData>({ resolver: zodResolver(selectionResultSchema), @@ -36,10 +49,53 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor }, }) + // 기존 선정결과 로드 + React.useEffect(() => { + const loadSelectionResult = async () => { + setIsLoading(true) + try { + const result = await getSelectionResult(biddingId) + if (result.success && result.data) { + form.reset({ + summary: result.data.summary || '', + }) + if (result.data.attachments) { + setExistingAttachments(result.data.attachments) + } + } + } catch (error) { + console.error('Failed to load selection result:', error) + toast({ + title: '로드 실패', + description: '선정결과를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + loadSelectionResult() + }, [biddingId, form, toast]) + const removeAttachmentFile = (index: number) => { setAttachmentFiles(prev => prev.filter((_, i) => i !== index)) } + const removeExistingAttachment = (id: number) => { + setExistingAttachments(prev => prev.filter(att => att.id !== id)) + } + + const downloadAttachment = (filePath: string, fileName: string) => { + // 파일 다운로드 (filePath가 절대 경로인 경우) + if (filePath.startsWith('http') || filePath.startsWith('/')) { + window.open(filePath, '_blank') + } else { + // 상대 경로인 경우 + window.open(`/api/files/${filePath}`, '_blank') + } + } + const onSubmit = async (data: SelectionResultFormData) => { setIsSubmitting(true) try { @@ -74,6 +130,22 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor } } + if (isLoading) { + return ( + <Card> + <CardHeader> + <CardTitle>선정결과</CardTitle> + </CardHeader> + <CardContent> + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">로딩 중...</span> + </div> + </CardContent> + </Card> + ) + } + return ( <Card> <CardHeader> @@ -94,6 +166,7 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor placeholder="선정결과에 대한 요약을 입력해주세요..." className="min-h-[120px]" {...field} + disabled={readOnly} /> </FormControl> <FormMessage /> @@ -104,35 +177,83 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor {/* 첨부파일 */} <div className="space-y-4"> <FormLabel>첨부파일</FormLabel> - <Dropzone - maxSize={10 * 1024 * 1024} // 10MB - onDropAccepted={(files) => { - const newFiles = Array.from(files) - setAttachmentFiles(prev => [...prev, ...newFiles]) - }} - onDropRejected={() => { - toast({ - title: "파일 업로드 거부", - description: "파일 크기 및 형식을 확인해주세요.", - variant: "destructive", - }) - }} - > - <DropzoneZone> - <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" /> - <DropzoneTitle className="text-lg font-medium"> - 파일을 드래그하거나 클릭하여 업로드 - </DropzoneTitle> - <DropzoneDescription className="text-sm text-muted-foreground"> - PDF, Word, Excel, 이미지 파일 (최대 10MB) - </DropzoneDescription> - </DropzoneZone> - <DropzoneInput /> - </Dropzone> + + {/* 기존 첨부파일 */} + {existingAttachments.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium">기존 첨부파일</h4> + <div className="space-y-2"> + {existingAttachments.map((attachment) => ( + <div + key={attachment.id} + className="flex items-center justify-between p-3 bg-muted rounded-lg" + > + <div className="flex items-center gap-3"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm font-medium">{attachment.originalFileName || attachment.fileName}</p> + <p className="text-xs text-muted-foreground"> + {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB + </p> + </div> + </div> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => downloadAttachment(attachment.filePath, attachment.originalFileName || attachment.fileName)} + > + <Download className="h-4 w-4" /> + </Button> + {!readOnly && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeExistingAttachment(attachment.id)} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ))} + </div> + </div> + )} + + {!readOnly && ( + <Dropzone + maxSize={10 * 1024 * 1024} // 10MB + onDropAccepted={(files) => { + const newFiles = Array.from(files) + setAttachmentFiles(prev => [...prev, ...newFiles]) + }} + onDropRejected={() => { + toast({ + title: "파일 업로드 거부", + description: "파일 크기 및 형식을 확인해주세요.", + variant: "destructive", + }) + }} + > + <DropzoneZone> + <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" /> + <DropzoneTitle className="text-lg font-medium"> + 파일을 드래그하거나 클릭하여 업로드 + </DropzoneTitle> + <DropzoneDescription className="text-sm text-muted-foreground"> + PDF, Word, Excel, 이미지 파일 (최대 10MB) + </DropzoneDescription> + </DropzoneZone> + <DropzoneInput /> + </Dropzone> + )} {attachmentFiles.length > 0 && ( <div className="space-y-2"> - <h4 className="text-sm font-medium">업로드된 파일</h4> + <h4 className="text-sm font-medium">새로 추가할 파일</h4> <div className="space-y-2"> {attachmentFiles.map((file, index) => ( <div @@ -148,14 +269,16 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor </p> </div> </div> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => removeAttachmentFile(index)} - > - 제거 - </Button> + {!readOnly && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeAttachmentFile(index)} + > + 제거 + </Button> + )} </div> ))} </div> @@ -164,13 +287,15 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor </div> {/* 저장 버튼 */} - <div className="flex justify-end"> - <Button type="submit" disabled={isSubmitting}> - {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - <Save className="mr-2 h-4 w-4" /> - 저장 - </Button> - </div> + {!readOnly && ( + <div className="flex justify-end"> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + <Save className="mr-2 h-4 w-4" /> + 저장 + </Button> + </div> + )} </form> </Form> </CardContent> diff --git a/lib/bidding/selection/vendor-selection-table.tsx b/lib/bidding/selection/vendor-selection-table.tsx index 8570b5b6..40f13ec1 100644 --- a/lib/bidding/selection/vendor-selection-table.tsx +++ b/lib/bidding/selection/vendor-selection-table.tsx @@ -10,9 +10,10 @@ interface VendorSelectionTableProps { biddingId: number bidding: Bidding onRefresh: () => void + readOnly?: boolean } -export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSelectionTableProps) { +export function VendorSelectionTable({ biddingId, bidding, onRefresh, readOnly = false }: VendorSelectionTableProps) { const [vendors, setVendors] = React.useState<any[]>([]) const [loading, setLoading] = React.useState(true) @@ -59,6 +60,7 @@ export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSe vendors={vendors} onRefresh={onRefresh} onOpenSelectionReasonDialog={() => {}} + readOnly={readOnly} /> </CardContent> </Card> |
