summaryrefslogtreecommitdiff
path: root/lib/bidding/selection
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-12 10:42:36 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-12 10:42:36 +0000
commit8642ee064ddf96f1db2b948b4cc8bbbd6cfee820 (patch)
tree36bd57d147ba929f1d72918d1fb91ad2c4778624 /lib/bidding/selection
parent57ea2f740abf1c7933671561cfe0e421fb5ef3fc (diff)
(최겸) 구매 일반계약, 입찰 수정
Diffstat (limited to 'lib/bidding/selection')
-rw-r--r--lib/bidding/selection/actions.ts219
-rw-r--r--lib/bidding/selection/bidding-info-card.tsx96
-rw-r--r--lib/bidding/selection/bidding-selection-detail-content.tsx41
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx4
-rw-r--r--lib/bidding/selection/biddings-selection-table.tsx4
-rw-r--r--lib/bidding/selection/selection-result-form.tsx143
-rw-r--r--lib/bidding/selection/vendor-selection-table.tsx66
7 files changed, 565 insertions, 8 deletions
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 (
+ <Card>
+ <CardHeader>
+ <CardTitle>입찰정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+ {/* 입찰명 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 입찰명
+ </label>
+ <div className="text-sm font-medium">
+ {bidding.title || '-'}
+ </div>
+ </div>
+
+ {/* 입찰번호 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 입찰번호
+ </label>
+ <div className="text-sm font-medium">
+ {bidding.biddingNumber || '-'}
+ </div>
+ </div>
+
+ {/* 내정가 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 내정가
+ </label>
+ <div className="text-sm font-medium">
+ {bidding.targetPrice
+ ? `${Number(bidding.targetPrice).toLocaleString()} ${bidding.currency || 'KRW'}`
+ : '-'
+ }
+ </div>
+ </div>
+
+ {/* 입찰유형 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 입찰유형
+ </label>
+ <div className="text-sm font-medium">
+ {bidding.isPublic ? '공개입찰' : '비공개입찰'}
+ </div>
+ </div>
+
+ {/* 진행상태 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 진행상태
+ </label>
+ <Badge variant="secondary">
+ {biddingStatusLabels[bidding.status as keyof typeof biddingStatusLabels] || bidding.status}
+ </Badge>
+ </div>
+
+ {/* 입찰담당자 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 입찰담당자
+ </label>
+ <div className="text-sm font-medium">
+ {bidding.bidPicName || '-'}
+ </div>
+ </div>
+
+ {/* 계약구분 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 계약구분
+ </label>
+ <div className="text-sm font-medium">
+ {contractTypeLabels[bidding.contractType as keyof typeof contractTypeLabels] || bidding.contractType}
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
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 (
+ <div className="space-y-6">
+ {/* 입찰정보 카드 */}
+ <BiddingInfoCard bidding={bidding} />
+
+ {/* 선정결과 폼 */}
+ <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} />
+
+ {/* 업체선정 테이블 */}
+ <VendorSelectionTable
+ key={refreshKey}
+ biddingId={biddingId}
+ bidding={bidding}
+ onRefresh={handleRefresh}
+ />
+ </div>
+ )
+}
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):
<Eye className="mr-2 h-4 w-4" />
상세보기
</DropdownMenuItem>
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "detail" })}>
- <FileText className="mr-2 h-4 w-4" />
- 상세분석
- </DropdownMenuItem>
{row.original.status === 'bidding_opened' && (
<>
<DropdownMenuSeparator />
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<typeof selectionResultSchema>
+
+interface SelectionResultFormProps {
+ biddingId: number
+ onSuccess: () => void
+}
+
+export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) {
+ const { toast } = useToast()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ const form = useForm<SelectionResultFormData>({
+ 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 (
+ <Card>
+ <CardHeader>
+ <CardTitle>선정결과</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 결과요약 */}
+ <FormField
+ control={form.control}
+ name="summary"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>결과요약</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="선정결과에 대한 요약을 입력해주세요..."
+ className="min-h-[120px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 첨부파일 */}
+ <FormField
+ control={form.control}
+ name="attachments"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>첨부파일</FormLabel>
+ <FormControl>
+ <FileUpload
+ value={field.value || []}
+ onChange={field.onChange}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif'],
+ }}
+ maxSize={10 * 1024 * 1024} // 10MB
+ maxFiles={5}
+ placeholder="선정결과 관련 문서를 업로드해주세요"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 저장 버튼 */}
+ <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>
+ </Card>
+ )
+}
diff --git a/lib/bidding/selection/vendor-selection-table.tsx b/lib/bidding/selection/vendor-selection-table.tsx
new file mode 100644
index 00000000..8570b5b6
--- /dev/null
+++ b/lib/bidding/selection/vendor-selection-table.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { BiddingDetailVendorTableContent } from '../detail/table/bidding-detail-vendor-table'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { getBiddingDetailData } from '../detail/service'
+
+interface VendorSelectionTableProps {
+ biddingId: number
+ bidding: Bidding
+ onRefresh: () => void
+}
+
+export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSelectionTableProps) {
+ const [vendors, setVendors] = React.useState<any[]>([])
+ const [loading, setLoading] = React.useState(true)
+
+ React.useEffect(() => {
+ const loadData = async () => {
+ try {
+ setLoading(true)
+ const data = await getBiddingDetailData(biddingId)
+ setVendors(data.quotationVendors)
+ } catch (error) {
+ console.error('Failed to load vendors:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ loadData()
+ }, [biddingId])
+
+ 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>
+ )
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>업체선정</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <BiddingDetailVendorTableContent
+ biddingId={biddingId}
+ bidding={bidding}
+ vendors={vendors}
+ onRefresh={onRefresh}
+ onOpenSelectionReasonDialog={() => {}}
+ />
+ </CardContent>
+ </Card>
+ )
+}