diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-12 10:42:36 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-12 10:42:36 +0000 |
| commit | 8642ee064ddf96f1db2b948b4cc8bbbd6cfee820 (patch) | |
| tree | 36bd57d147ba929f1d72918d1fb91ad2c4778624 /lib/bidding/selection | |
| parent | 57ea2f740abf1c7933671561cfe0e421fb5ef3fc (diff) | |
(최겸) 구매 일반계약, 입찰 수정
Diffstat (limited to 'lib/bidding/selection')
| -rw-r--r-- | lib/bidding/selection/actions.ts | 219 | ||||
| -rw-r--r-- | lib/bidding/selection/bidding-info-card.tsx | 96 | ||||
| -rw-r--r-- | lib/bidding/selection/bidding-selection-detail-content.tsx | 41 | ||||
| -rw-r--r-- | lib/bidding/selection/biddings-selection-columns.tsx | 4 | ||||
| -rw-r--r-- | lib/bidding/selection/biddings-selection-table.tsx | 4 | ||||
| -rw-r--r-- | lib/bidding/selection/selection-result-form.tsx | 143 | ||||
| -rw-r--r-- | lib/bidding/selection/vendor-selection-table.tsx | 66 |
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> + ) +} |
