From a2bc455f654e011c53968b0d3a14389d7259847e Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 3 Sep 2025 10:35:57 +0000 Subject: (최겸) 구매 입찰 개발(벤더 응찰 개발 및 기본계약 요청 개발 필) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/detail/service.ts | 174 +++++++++++++- .../detail/table/bidding-detail-content.tsx | 37 ++- lib/bidding/detail/table/bidding-detail-header.tsx | 81 +------ .../detail/table/bidding-detail-vendor-columns.tsx | 39 +++- .../detail/table/bidding-detail-vendor-table.tsx | 46 +++- .../bidding-detail-vendor-toolbar-actions.tsx | 152 ++++++++++++- lib/bidding/list/biddings-table-columns.tsx | 4 +- lib/bidding/list/create-bidding-dialog.tsx | 253 +++++++++++++++++++-- lib/bidding/service.ts | 143 ++++++++++-- lib/bidding/validation.ts | 14 ++ lib/bidding/vendor/partners-bidding-detail.tsx | 241 +++++++++++++++++++- 11 files changed, 1022 insertions(+), 162 deletions(-) (limited to 'lib/bidding') diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 2ce17713..c811f46d 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -1,7 +1,8 @@ 'use server' import db from '@/db/db' -import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions } from '@/db/schema' +import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions, priceAdjustmentForms } from '@/db/schema' +import { specificationMeetings } from '@/db/schema/bidding' import { eq, and, sql, desc, ne } from 'drizzle-orm' import { revalidatePath } from 'next/cache' @@ -224,6 +225,7 @@ export async function getQuotationVendors(biddingId: number): Promise 0) { // 업데이트 await tx .update(companyConditionResponses) .set(responseData) .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) + + companyConditionResponseId = existingResponse[0].id } else { // 새로 생성 - await tx + const [newResponse] = await tx .insert(companyConditionResponses) .values({ biddingCompanyId, ...responseData, }) + .returning({ id: companyConditionResponses.id }) + + companyConditionResponseId = newResponse.id + } + + // 3. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) + if (response.priceAdjustmentResponse && response.priceAdjustmentForm) { + const priceAdjustmentData = { + companyConditionResponsesId: companyConditionResponseId, + itemName: response.priceAdjustmentForm.itemName, + adjustmentReflectionPoint: response.priceAdjustmentForm.adjustmentReflectionPoint, + majorApplicableRawMaterial: response.priceAdjustmentForm.majorApplicableRawMaterial, + adjustmentFormula: response.priceAdjustmentForm.adjustmentFormula, + rawMaterialPriceIndex: response.priceAdjustmentForm.rawMaterialPriceIndex, + referenceDate: response.priceAdjustmentForm.referenceDate ? new Date(response.priceAdjustmentForm.referenceDate) : null, + comparisonDate: response.priceAdjustmentForm.comparisonDate ? new Date(response.priceAdjustmentForm.comparisonDate) : null, + adjustmentRatio: response.priceAdjustmentForm.adjustmentRatio, + notes: response.priceAdjustmentForm.notes, + adjustmentConditions: response.priceAdjustmentForm.adjustmentConditions, + majorNonApplicableRawMaterial: response.priceAdjustmentForm.majorNonApplicableRawMaterial, + adjustmentPeriod: response.priceAdjustmentForm.adjustmentPeriod, + contractorWriter: response.priceAdjustmentForm.contractorWriter, + adjustmentDate: response.priceAdjustmentForm.adjustmentDate ? new Date(response.priceAdjustmentForm.adjustmentDate) : null, + nonApplicableReason: response.priceAdjustmentForm.nonApplicableReason, + } + + // 기존 연동제 정보가 있는지 확인 + const existingPriceAdjustment = await tx + .select() + .from(priceAdjustmentForms) + .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + .limit(1) + + if (existingPriceAdjustment.length > 0) { + // 업데이트 + await tx + .update(priceAdjustmentForms) + .set(priceAdjustmentData) + .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + } else { + // 새로 생성 + await tx.insert(priceAdjustmentForms).values(priceAdjustmentData) + } } // 2. biddingCompanies 테이블에 견적 금액과 상태 업데이트 @@ -940,6 +1010,26 @@ export async function submitPartnerResponse( // 사양설명회 정보 조회 (협력업체용) export async function getSpecificationMeetingForPartners(biddingId: number) { try { + // specification_meetings 테이블에서 사양설명회 정보 조회 + const specMeeting = await db + .select({ + id: specificationMeetings.id, + meetingDate: specificationMeetings.meetingDate, + meetingTime: specificationMeetings.meetingTime, + location: specificationMeetings.location, + address: specificationMeetings.address, + contactPerson: specificationMeetings.contactPerson, + contactPhone: specificationMeetings.contactPhone, + contactEmail: specificationMeetings.contactEmail, + agenda: specificationMeetings.agenda, + materials: specificationMeetings.materials, + notes: specificationMeetings.notes, + isRequired: specificationMeetings.isRequired, + }) + .from(specificationMeetings) + .where(eq(specificationMeetings.biddingId, biddingId)) + .limit(1) + // bidding_documents에서 사양설명회 관련 문서 조회 const documents = await db .select({ @@ -956,17 +1046,12 @@ export async function getSpecificationMeetingForPartners(biddingId: number) { eq(biddingDocuments.documentType, 'specification_meeting') )) - // biddings 테이블에서 사양설명회 기본 정보 조회 + // 기본 입찰 정보도 가져오기 (제목, 입찰번호 등) const bidding = await db .select({ id: biddings.id, title: biddings.title, biddingNumber: biddings.biddingNumber, - preQuoteDate: biddings.preQuoteDate, - biddingRegistrationDate: biddings.biddingRegistrationDate, - managerName: biddings.managerName, - managerEmail: biddings.managerEmail, - managerPhone: biddings.managerPhone, }) .from(biddings) .where(eq(biddings.id, biddingId)) @@ -976,15 +1061,44 @@ export async function getSpecificationMeetingForPartners(biddingId: number) { return { success: false, error: '입찰 정보를 찾을 수 없습니다.' } } + // 사양설명회 정보가 없는 경우 + if (specMeeting.length === 0) { + return { + success: true, + data: { + ...bidding[0], + documents, + meetingDate: null, + meetingTime: null, + location: null, + address: null, + contactPerson: null, + contactPhone: null, + contactEmail: null, + agenda: null, + materials: null, + notes: null, + isRequired: false, + } + } + } + return { success: true, data: { ...bidding[0], documents, - meetingDate: bidding[0].preQuoteDate ? bidding[0].preQuoteDate.toISOString().split('T')[0] : null, - contactPerson: bidding[0].managerName, - contactEmail: bidding[0].managerEmail, - contactPhone: bidding[0].managerPhone, + meetingDate: specMeeting[0].meetingDate ? specMeeting[0].meetingDate.toISOString().split('T')[0] : null, + meetingTime: specMeeting[0].meetingTime, + location: specMeeting[0].location, + address: specMeeting[0].address, + contactPerson: specMeeting[0].contactPerson, + contactPhone: specMeeting[0].contactPhone, + contactEmail: specMeeting[0].contactEmail, + agenda: specMeeting[0].agenda, + materials: specMeeting[0].materials, + notes: specMeeting[0].notes, + isRequired: specMeeting[0].isRequired, } } } catch (error) { @@ -1094,3 +1208,39 @@ export async function updatePartnerAttendance( return { success: false, error: '참석 여부 업데이트에 실패했습니다.' } } } + +// 연동제 정보 조회 +export async function getPriceAdjustmentForm(companyConditionResponseId: number) { + try { + const priceAdjustment = await db + .select() + .from(priceAdjustmentForms) + .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + .limit(1) + + return priceAdjustment[0] || null + } catch (error) { + console.error('Failed to get price adjustment form:', error) + return null + } +} + +// 입찰업체 ID로 연동제 정보 조회 +export async function getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId: number) { + try { + const result = await db + .select({ + priceAdjustmentForm: priceAdjustmentForms, + companyConditionResponse: companyConditionResponses, + }) + .from(companyConditionResponses) + .leftJoin(priceAdjustmentForms, eq(companyConditionResponses.id, priceAdjustmentForms.companyConditionResponsesId)) + .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) + .limit(1) + + return result[0]?.priceAdjustmentForm || null + } catch (error) { + console.error('Failed to get price adjustment form by bidding company id:', error) + return null + } +} diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx index 090e7218..50f0941e 100644 --- a/lib/bidding/detail/table/bidding-detail-content.tsx +++ b/lib/bidding/detail/table/bidding-detail-content.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { Bidding } from '@/db/schema' import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service' -import { BiddingDetailHeader } from './bidding-detail-header' + import { BiddingDetailVendorTableContent } from './bidding-detail-vendor-table' import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog' import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog' @@ -45,27 +45,20 @@ export function BiddingDetailContent({ }, []) return ( -
-
-
- - -
- openDialog('items')} - onOpenTargetPriceDialog={() => openDialog('targetPrice')} - onOpenSelectionReasonDialog={() => openDialog('selectionReason')} - onEdit={undefined} - onDelete={undefined} - onSelectWinner={undefined} - /> -
-
-
+
+ openDialog('items')} + onOpenTargetPriceDialog={() => openDialog('targetPrice')} + onOpenSelectionReasonDialog={() => openDialog('selectionReason')} + onEdit={undefined} + onDelete={undefined} + onSelectWinner={undefined} + /> - - 목록으로 - - ) + // 모든 액션 버튼을 항상 표시 (상태 검증은 각 핸들러에서) buttons.push( @@ -228,74 +218,9 @@ export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) {
{/* 세부 정보 영역 */} -
- {/* 프로젝트 정보 */} - {bidding.projectName && ( -
- - 프로젝트: - {bidding.projectName} -
- )} - - {/* 품목 정보 */} - {bidding.itemName && ( -
- - 품목: - {bidding.itemName} -
- )} - - {/* 담당자 정보 */} - {bidding.managerName && ( -
- - 담당자: - {bidding.managerName} -
- )} - - {/* 계약구분 */} -
- 계약: - {contractTypeLabels[bidding.contractType]} -
- - {/* 입찰유형 */} -
- 유형: - {biddingTypeLabels[bidding.biddingType]} -
- - {/* 낙찰수 */} -
- 낙찰: - {bidding.awardCount === 'single' ? '단수' : '복수'} -
- - {/* 통화 */} -
- - {bidding.currency} -
- - {/* 예산 정보 */} - {bidding.budget && ( -
- 예산: - - {new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: bidding.currency || 'KRW', - }).format(Number(bidding.budget))} - -
- )} -
{/* 일정 정보 */} - {(bidding.submissionStartDate || bidding.evaluationDate || bidding.preQuoteDate || bidding.biddingRegistrationDate) && ( + {/* {(bidding.submissionStartDate || bidding.evaluationDate || bidding.preQuoteDate || bidding.biddingRegistrationDate) && (
@@ -321,7 +246,7 @@ export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) { )}
- )} + )} */}
) diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 9e06d5d1..6f02497f 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -22,12 +22,14 @@ interface GetVendorColumnsProps { onEdit: (vendor: QuotationVendor) => void onDelete: (vendor: QuotationVendor) => void onSelectWinner: (vendor: QuotationVendor) => void + onViewPriceAdjustment?: (vendor: QuotationVendor) => void } export function getBiddingDetailVendorColumns({ onEdit, onDelete, - onSelectWinner + onSelectWinner, + onViewPriceAdjustment }: GetVendorColumnsProps): ColumnDef[] { return [ { @@ -139,13 +141,46 @@ export function getBiddingDetailVendorColumns({ {row.original.incotermsResponse || '-'} ), + }, + { + accessorKey: 'isInitialResponse', + header: '초도여부', + cell: ({ row }) => ( + + {row.original.isInitialResponse ? 'Y' : 'N'} + + ), + }, + { + accessorKey: 'priceAdjustmentResponse', + header: '연동제', + cell: ({ row }) => { + const hasPriceAdjustment = row.original.priceAdjustmentResponse + return ( +
+ + {hasPriceAdjustment ? '적용' : '미적용'} + + {hasPriceAdjustment && onViewPriceAdjustment && ( + + )} +
+ ) + }, }, { accessorKey: 'proposedContractDeliveryDate', header: '제안납기일', cell: ({ row }) => (
- {row.original.proposedContractDeliveryDate ? + {row.original.proposedContractDeliveryDate ? new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'}
), diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index 7ad7056c..b1f0b08e 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -9,7 +9,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog' import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns' -import { QuotationVendor } from '@/lib/bidding/detail/service' +import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service' +import { Bidding } from '@/db/schema' +import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog' import { deleteQuotationVendor, selectWinner @@ -20,6 +22,7 @@ import { useTransition } from 'react' interface BiddingDetailVendorTableContentProps { biddingId: number + bidding: Bidding vendors: QuotationVendor[] onRefresh: () => void onOpenItemsDialog: () => void @@ -83,6 +86,7 @@ const advancedFilterFields: DataTableAdvancedFilterField[] = [ export function BiddingDetailVendorTableContent({ biddingId, + bidding, vendors, onRefresh, onOpenItemsDialog, @@ -96,6 +100,8 @@ export function BiddingDetailVendorTableContent({ const [isPending, startTransition] = useTransition() const [selectedVendor, setSelectedVendor] = React.useState(null) const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) + const [priceAdjustmentData, setPriceAdjustmentData] = React.useState(null) + const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) const handleDelete = (vendor: QuotationVendor) => { if (!confirm(`${vendor.vendorName} 업체를 삭제하시겠습니까?`)) return @@ -170,13 +176,38 @@ export function BiddingDetailVendorTableContent({ setIsEditDialogOpen(true) } + const handleViewPriceAdjustment = async (vendor: QuotationVendor) => { + try { + const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(vendor.id) + if (priceAdjustmentForm) { + setPriceAdjustmentData(priceAdjustmentForm) + setSelectedVendor(vendor) + setIsPriceAdjustmentDialogOpen(true) + } else { + toast({ + title: '연동제 정보 없음', + description: '해당 업체의 연동제 정보가 없습니다.', + variant: 'default', + }) + } + } catch (error) { + console.error('Failed to load price adjustment form:', error) + toast({ + title: '오류', + description: '연동제 정보를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } + } + const columns = React.useMemo( () => getBiddingDetailVendorColumns({ onEdit: onEdit || handleEdit, onDelete: onDelete || handleDelete, - onSelectWinner: onSelectWinner || handleSelectWinner + onSelectWinner: onSelectWinner || handleSelectWinner, + onViewPriceAdjustment: handleViewPriceAdjustment }), - [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner] + [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner, handleViewPriceAdjustment] ) const { table } = useDataTable({ @@ -205,10 +236,10 @@ export function BiddingDetailVendorTableContent({ @@ -220,6 +251,13 @@ export function BiddingDetailVendorTableContent({ onOpenChange={setIsEditDialogOpen} onSuccess={onRefresh} /> + + ) } diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index 00daa005..ca9ffc60 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -2,38 +2,184 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" +import { useRouter } from "next/navigation" +import { useTransition } from "react" import { Button } from "@/components/ui/button" -import { Plus } from "lucide-react" -import { QuotationVendor } from "@/lib/bidding/detail/service" +import { Plus, Send, RotateCcw, XCircle } from "lucide-react" +import { QuotationVendor, registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service" import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog" +import { Bidding } from "@/db/schema" +import { useToast } from "@/hooks/use-toast" interface BiddingDetailVendorToolbarActionsProps { table: Table biddingId: number + bidding: Bidding onOpenItemsDialog: () => void onOpenTargetPriceDialog: () => void onOpenSelectionReasonDialog: () => void - onSuccess: () => void } export function BiddingDetailVendorToolbarActions({ table, biddingId, + bidding, onOpenItemsDialog, onOpenTargetPriceDialog, onOpenSelectionReasonDialog, onSuccess }: BiddingDetailVendorToolbarActionsProps) { + const router = useRouter() + const { toast } = useToast() + const [isPending, startTransition] = useTransition() const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false) const handleCreateVendor = () => { setIsCreateDialogOpen(true) } + const handleRegister = () => { + // 상태 검증 + if (bidding.status !== 'bidding_generated') { + toast({ + title: '실행 불가', + description: '입찰 등록은 입찰 생성 상태에서만 가능합니다.', + variant: 'destructive', + }) + return + } + + if (!confirm('입찰을 등록하시겠습니까?')) return + + startTransition(async () => { + const result = await registerBidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + router.refresh() + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const handleMarkAsDisposal = () => { + // 상태 검증 + if (bidding.status !== 'bidding_closed') { + toast({ + title: '실행 불가', + description: '유찰 처리는 입찰 마감 상태에서만 가능합니다.', + variant: 'destructive', + }) + return + } + + if (!confirm('입찰을 유찰 처리하시겠습니까?')) return + + startTransition(async () => { + const result = await markAsDisposal(bidding.id, 'current-user') // TODO: 실제 사용자 ID + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + router.refresh() + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const handleCreateRebidding = () => { + // 상태 검증 + if (bidding.status !== 'bidding_disposal') { + toast({ + title: '실행 불가', + description: '재입찰은 유찰 상태에서만 가능합니다.', + variant: 'destructive', + }) + return + } + + if (!confirm('재입찰을 생성하시겠습니까?')) return + + startTransition(async () => { + const result = await createRebidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + if (result.data?.redirectTo) { + router.push(result.data.redirectTo) + } else { + router.refresh() + } + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + return ( <>
+ {/* 상태별 액션 버튼 */} + {/* {bidding.status === 'bidding_generated' && ( + + )} + + {bidding.status === 'bidding_closed' && ( + + )} + + {bidding.status === 'bidding_disposal' && ( + + )} */} + + {/* 기존 버튼들 */} - + {/* 고정 헤더 */}
@@ -586,29 +617,87 @@ export function CreateBiddingDialog() { {/* 탭 영역 */}
-
- - - 기본 정보 +
+
+ + + + + + +
@@ -1193,6 +1282,128 @@ export function CreateBiddingDialog() { + {/* 입찰 조건 탭 */} + + + + 입찰 조건 +

+ 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요 +

+
+ +
+
+ + setBiddingConditions(prev => ({ + ...prev, + paymentTerms: e.target.value + }))} + /> +
+ +
+ + setBiddingConditions(prev => ({ + ...prev, + taxConditions: e.target.value + }))} + /> +
+ +
+ + setBiddingConditions(prev => ({ + ...prev, + incoterms: e.target.value + }))} + /> +
+ +
+ + setBiddingConditions(prev => ({ + ...prev, + contractDeliveryDate: e.target.value + }))} + /> +
+ +
+ + setBiddingConditions(prev => ({ + ...prev, + shippingPort: e.target.value + }))} + /> +
+ +
+ + setBiddingConditions(prev => ({ + ...prev, + destinationPort: e.target.value + }))} + /> +
+
+ +
+ setBiddingConditions(prev => ({ + ...prev, + isPriceAdjustmentApplicable: checked + }))} + /> + +
+ +
+ +