From 8642ee064ddf96f1db2b948b4cc8bbbd6cfee820 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 12 Nov 2025 10:42:36 +0000 Subject: (최겸) 구매 일반계약, 입찰 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/selection/actions.ts | 219 +++++++++++++++++++++ lib/bidding/selection/bidding-info-card.tsx | 96 +++++++++ .../selection/bidding-selection-detail-content.tsx | 41 ++++ .../selection/biddings-selection-columns.tsx | 4 - lib/bidding/selection/biddings-selection-table.tsx | 4 - lib/bidding/selection/selection-result-form.tsx | 143 ++++++++++++++ lib/bidding/selection/vendor-selection-table.tsx | 66 +++++++ 7 files changed, 565 insertions(+), 8 deletions(-) create mode 100644 lib/bidding/selection/actions.ts create mode 100644 lib/bidding/selection/bidding-info-card.tsx create mode 100644 lib/bidding/selection/bidding-selection-detail-content.tsx create mode 100644 lib/bidding/selection/selection-result-form.tsx create mode 100644 lib/bidding/selection/vendor-selection-table.tsx (limited to 'lib/bidding/selection') diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts new file mode 100644 index 00000000..e17e9292 --- /dev/null +++ b/lib/bidding/selection/actions.ts @@ -0,0 +1,219 @@ +"use server" + +import db from "@/db/db" +import { eq, and, sql, isNull } from "drizzle-orm" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +// @ts-ignore - Next.js cache import issue in server actions +const { revalidatePath } = require("next/cache") +import { + biddings, + biddingCompanies, + prItemsForBidding, + companyPrItemBids, + vendors, + generalContracts, + generalContractItems, + vendorSelectionResults, + biddingDocuments +} from "@/db/schema" + +interface SaveSelectionResultData { + biddingId: number + summary: string + attachments?: File[] +} + +export async function saveSelectionResult(data: SaveSelectionResultData) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { + success: false, + error: '인증되지 않은 사용자입니다.' + } + } + + // 기존 선정결과 확인 (selectedCompanyId가 null인 레코드) + // 타입 에러를 무시하고 전체 조회 후 필터링 + const allResults = await db + .select() + .from(vendorSelectionResults) + .where(eq(vendorSelectionResults.biddingId, data.biddingId)) + + // @ts-ignore + const existingResult = allResults.filter((result: any) => result.selectedCompanyId === null).slice(0, 1) + + const resultData = { + biddingId: data.biddingId, + selectedCompanyId: null, // 전체 선정결과 + selectionReason: '전체 선정결과', + evaluationSummary: data.summary, + hasResultDocuments: data.attachments && data.attachments.length > 0, + selectedBy: session.user.id + } + + let resultId: number + + if (existingResult.length > 0) { + // 업데이트 + await db + .update(vendorSelectionResults) + .set({ + ...resultData, + updatedAt: new Date() + }) + .where(eq(vendorSelectionResults.id, existingResult[0].id)) + resultId = existingResult[0].id + } else { + // 새로 생성 + const insertResult = await db.insert(vendorSelectionResults).values(resultData).returning({ id: vendorSelectionResults.id }) + resultId = insertResult[0].id + } + + // 첨부파일 처리 + if (data.attachments && data.attachments.length > 0) { + // 기존 첨부파일 삭제 (documentType이 'selection_result'인 것들) + await db + .delete(biddingDocuments) + .where(and( + eq(biddingDocuments.biddingId, data.biddingId), + eq(biddingDocuments.documentType, 'selection_result') + )) + + // 새 첨부파일 저장 + const documentInserts = data.attachments.map(file => ({ + biddingId: data.biddingId, + companyId: null, + documentType: 'selection_result' as const, + fileName: file.name, + originalFileName: file.name, + fileSize: file.size, + mimeType: file.type, + filePath: `/uploads/bidding/${data.biddingId}/selection/${file.name}`, // 실제 파일 저장 로직 필요 + uploadedBy: session.user.id + })) + + await db.insert(biddingDocuments).values(documentInserts) + } + + revalidatePath(`/evcp/bid-selection/${data.biddingId}/detail`) + + return { + success: true, + message: '선정결과가 성공적으로 저장되었습니다.' + } + } catch (error) { + console.error('Failed to save selection result:', error) + return { + success: false, + error: '선정결과 저장 중 오류가 발생했습니다.' + } + } +} + +// 견적 히스토리 조회 +export async function getQuotationHistory(biddingId: number, vendorId: number) { + try { + // biddingCompanies에서 해당 벤더의 스냅샷 데이터 조회 + const companyData = await db + .select({ + quotationSnapshots: biddingCompanies.quotationSnapshots + }) + .from(biddingCompanies) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.companyId, vendorId) + )) + .limit(1) + + if (!companyData.length || !companyData[0].quotationSnapshots) { + return { + success: true, + data: { + history: [] + } + } + } + + const snapshots = companyData[0].quotationSnapshots as any[] + + // PR 항목 정보 조회 (스냅샷의 prItemId로 매핑하기 위해) + const prItemIds = snapshots.flatMap(snapshot => + snapshot.items?.map((item: any) => item.prItemId) || [] + ).filter((id: number, index: number, arr: number[]) => arr.indexOf(id) === index) + + const prItems = prItemIds.length > 0 ? await db + .select({ + id: prItemsForBidding.id, + itemCode: prItemsForBidding.itemCode, + itemName: prItemsForBidding.itemName, + specification: prItemsForBidding.specification, + quantity: prItemsForBidding.quantity, + unit: prItemsForBidding.unit, + deliveryDate: prItemsForBidding.deliveryDate + }) + .from(prItemsForBidding) + .where(sql`${prItemsForBidding.id} IN ${prItemIds}`) : [] + + // PR 항목을 Map으로 변환하여 빠른 조회를 위해 + const prItemMap = new Map(prItems.map(item => [item.id, item])) + + // bidding 정보 조회 (targetPrice, currency) + const biddingInfo = await db + .select({ + targetPrice: biddings.targetPrice, + currency: biddings.currency + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + const targetPrice = biddingInfo[0]?.targetPrice ? parseFloat(biddingInfo[0].targetPrice.toString()) : null + const currency = biddingInfo[0]?.currency || 'KRW' + + // 스냅샷 데이터를 변환 + const history = snapshots.map((snapshot: any) => { + const vsTargetPrice = targetPrice && targetPrice > 0 + ? ((snapshot.totalAmount - targetPrice) / targetPrice) * 100 + : 0 + + const items = snapshot.items?.map((item: any) => { + const prItem = prItemMap.get(item.prItemId) + return { + itemCode: prItem?.itemCode || `ITEM${item.prItemId}`, + itemName: prItem?.itemName || '품목 정보 없음', + specification: prItem?.specification || item.technicalSpecification || '-', + quantity: prItem?.quantity || 0, + unit: prItem?.unit || 'EA', + unitPrice: item.bidUnitPrice, + totalPrice: item.bidAmount, + deliveryDate: item.proposedDeliveryDate ? new Date(item.proposedDeliveryDate) : prItem?.deliveryDate ? new Date(prItem.deliveryDate) : new Date() + } + }) || [] + + return { + id: snapshot.id, + round: snapshot.round, + submittedAt: new Date(snapshot.submittedAt), + totalAmount: snapshot.totalAmount, + currency: snapshot.currency || currency, + vsTargetPrice: parseFloat(vsTargetPrice.toFixed(2)), + items + } + }) + + return { + success: true, + data: { + history + } + } + } catch (error) { + console.error('Failed to get quotation history:', error) + return { + success: false, + error: '견적 히스토리 조회 중 오류가 발생했습니다.' + } + } +} diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx new file mode 100644 index 00000000..f6f0bc69 --- /dev/null +++ b/lib/bidding/selection/bidding-info-card.tsx @@ -0,0 +1,96 @@ +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +// import { formatDate } from '@/lib/utils' +import { biddingStatusLabels, contractTypeLabels } from '@/db/schema' + +interface BiddingInfoCardProps { + bidding: Bidding +} + +export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) { + return ( + + + 입찰정보 + + +
+ {/* 입찰명 */} +
+ +
+ {bidding.title || '-'} +
+
+ + {/* 입찰번호 */} +
+ +
+ {bidding.biddingNumber || '-'} +
+
+ + {/* 내정가 */} +
+ +
+ {bidding.targetPrice + ? `${Number(bidding.targetPrice).toLocaleString()} ${bidding.currency || 'KRW'}` + : '-' + } +
+
+ + {/* 입찰유형 */} +
+ +
+ {bidding.isPublic ? '공개입찰' : '비공개입찰'} +
+
+ + {/* 진행상태 */} +
+ + + {biddingStatusLabels[bidding.status as keyof typeof biddingStatusLabels] || bidding.status} + +
+ + {/* 입찰담당자 */} +
+ +
+ {bidding.bidPicName || '-'} +
+
+ + {/* 계약구분 */} +
+ +
+ {contractTypeLabels[bidding.contractType as keyof typeof contractTypeLabels] || bidding.contractType} +
+
+
+
+
+ ) +} diff --git a/lib/bidding/selection/bidding-selection-detail-content.tsx b/lib/bidding/selection/bidding-selection-detail-content.tsx new file mode 100644 index 00000000..45d5d402 --- /dev/null +++ b/lib/bidding/selection/bidding-selection-detail-content.tsx @@ -0,0 +1,41 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { BiddingInfoCard } from './bidding-info-card' +import { SelectionResultForm } from './selection-result-form' +import { VendorSelectionTable } from './vendor-selection-table' + +interface BiddingSelectionDetailContentProps { + biddingId: number + bidding: Bidding +} + +export function BiddingSelectionDetailContent({ + biddingId, + bidding +}: BiddingSelectionDetailContentProps) { + const [refreshKey, setRefreshKey] = React.useState(0) + + const handleRefresh = React.useCallback(() => { + setRefreshKey(prev => prev + 1) + }, []) + + return ( +
+ {/* 입찰정보 카드 */} + + + {/* 선정결과 폼 */} + + + {/* 업체선정 테이블 */} + +
+ ) +} diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx index bbcd2d77..0d1a8c9d 100644 --- a/lib/bidding/selection/biddings-selection-columns.tsx +++ b/lib/bidding/selection/biddings-selection-columns.tsx @@ -256,10 +256,6 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps): 상세보기 - setRowAction({ row, type: "detail" })}> - - 상세분석 - {row.original.status === 'bidding_opened' && ( <> diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx index 912a7154..9545fe09 100644 --- a/lib/bidding/selection/biddings-selection-table.tsx +++ b/lib/bidding/selection/biddings-selection-table.tsx @@ -83,10 +83,6 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps switch (rowAction.type) { case "view": // 상세 페이지로 이동 - router.push(`/evcp/bid/${rowAction.row.original.id}`) - break - case "detail": - // 상세분석 페이지로 이동 (추후 구현) router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`) break case "close_bidding": diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx new file mode 100644 index 00000000..7f1229a2 --- /dev/null +++ b/lib/bidding/selection/selection-result-form.tsx @@ -0,0 +1,143 @@ +'use client' + +import * as React from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import * as z from 'zod' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { FileUpload } from '@/components/ui/file-upload' +import { useToast } from '@/hooks/use-toast' +import { saveSelectionResult } from './actions' +import { Loader2, Save } from 'lucide-react' + +const selectionResultSchema = z.object({ + summary: z.string().min(1, '결과요약을 입력해주세요'), + attachments: z.array(z.any()).optional(), +}) + +type SelectionResultFormData = z.infer + +interface SelectionResultFormProps { + biddingId: number + onSuccess: () => void +} + +export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) { + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = React.useState(false) + + const form = useForm({ + resolver: zodResolver(selectionResultSchema), + defaultValues: { + summary: '', + attachments: [], + }, + }) + + const onSubmit = async (data: SelectionResultFormData) => { + setIsSubmitting(true) + try { + const result = await saveSelectionResult({ + biddingId, + summary: data.summary, + attachments: data.attachments + }) + + if (result.success) { + toast({ + title: '저장 완료', + description: result.message, + }) + onSuccess() + } else { + toast({ + title: '저장 실패', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + console.error('Failed to save selection result:', error) + toast({ + title: '저장 실패', + description: '선정결과 저장 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + 선정결과 + + +
+ + {/* 결과요약 */} + ( + + 결과요약 + +