From 86b1fd1cc801f45642f84d24c0b5c84368454ff0 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 9 Sep 2025 10:34:05 +0000 Subject: (최겸) 구매 입찰 사전견적, 입찰, 낙찰, 유찰, 재입찰 기능 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/detail/service.ts | 62 ++++++++++++++-------- .../detail/table/bidding-detail-content.tsx | 42 +++++++++++++++ lib/bidding/detail/table/bidding-detail-header.tsx | 49 ----------------- .../detail/table/bidding-detail-vendor-columns.tsx | 43 ++++++++++++--- .../table/bidding-detail-vendor-edit-dialog.tsx | 27 +++++++++- .../detail/table/bidding-detail-vendor-table.tsx | 20 ++++--- .../bidding-detail-vendor-toolbar-actions.tsx | 29 +++++----- 7 files changed, 172 insertions(+), 100 deletions(-) (limited to 'lib/bidding/detail') diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 956c1798..d9bcb255 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -71,7 +71,7 @@ export interface QuotationVendor { quotationAmount: number // 견적금액 currency: string submissionDate: string // 제출일 - isWinner: boolean // 낙찰여부 + isWinner: boolean | null // 낙찰여부 (null: 미정, true: 낙찰, false: 탈락) awardRatio: number | null // 발주비율 isBiddingParticipated: boolean | null // 본입찰 참여여부 status: 'pending' | 'submitted' | 'selected' | 'rejected' @@ -262,7 +262,7 @@ export async function getQuotationVendors(biddingId: number): Promise 0) { + // 업데이트 + await tx + .update(vendorSelectionResults) + .set({ selectedCompanyId: firstAwardedCompany.companyId, selectionReason, selectedBy: userId, selectedAt: new Date(), updatedAt: new Date() - } - }) + }) + .where(eq(vendorSelectionResults.biddingId, biddingId)) + } else { + // 삽입 + await tx + .insert(vendorSelectionResults) + .values({ + biddingId, + selectedCompanyId: firstAwardedCompany.companyId, + selectionReason, + selectedBy: userId, + selectedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + } }) @@ -1644,12 +1657,14 @@ export interface PartnersBiddingListItem { isWinner: boolean | null isAttendingMeeting: boolean | null isPreQuoteSelected: boolean | null + isPreQuoteParticipated: boolean | null + preQuoteDeadline: Date | null isBiddingInvited: boolean | null notes: string | null createdAt: Date updatedAt: Date // updatedBy: string | null - + hasSpecificationMeeting: boolean | null // biddings 정보 biddingId: number biddingNumber: string @@ -1688,6 +1703,8 @@ export async function getBiddingListForPartners(companyId: number): Promise(null) + const [prItemsForDialog, setPrItemsForDialog] = React.useState([]) const handleRefresh = React.useCallback(() => { setRefreshTrigger(prev => prev + 1) @@ -42,6 +54,25 @@ export function BiddingDetailContent({ setDialogStates(prev => ({ ...prev, [type]: false })) }, []) + const handleViewItemDetails = React.useCallback((vendor: QuotationVendor) => { + startTransition(async () => { + try { + // PR 아이템 정보 로드 + const prItemsData = await getPrItemsForBidding(bidding.id) + setPrItemsForDialog(prItemsData) + setSelectedVendorForDetails(vendor) + setIsItemDetailsDialogOpen(true) + } catch (error) { + console.error('Failed to load PR items:', error) + toast({ + title: '오류', + description: '품목 정보를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } + }) + }, [bidding.id, toast]) + return (
openDialog('targetPrice')} onOpenSelectionReasonDialog={() => openDialog('selectionReason')} onOpenAwardDialog={() => openDialog('award')} + onViewItemDetails={handleViewItemDetails} onEdit={undefined} onDelete={undefined} onSelectWinner={undefined} @@ -72,6 +104,16 @@ export function BiddingDetailContent({ bidding={bidding} onSuccess={handleRefresh} /> + +
) } diff --git a/lib/bidding/detail/table/bidding-detail-header.tsx b/lib/bidding/detail/table/bidding-detail-header.tsx index fcbbeb9a..2798478c 100644 --- a/lib/bidding/detail/table/bidding-detail-header.tsx +++ b/lib/bidding/detail/table/bidding-detail-header.tsx @@ -141,50 +141,6 @@ export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) { }) } - const getActionButtons = () => { - const buttons = [] - - // 기본 액션 버튼들 (항상 표시) - - - // 모든 액션 버튼을 항상 표시 (상태 검증은 각 핸들러에서) - buttons.push( - - ) - - buttons.push( - - ) - - buttons.push( - - ) - - return buttons - } - return (
@@ -209,11 +165,6 @@ export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) {
- - {/* 액션 버튼들 */} -
- {getActionButtons()} -
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index bb1d2c62..cbdf79c2 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -23,6 +23,7 @@ interface GetVendorColumnsProps { onDelete: (vendor: QuotationVendor) => void onSelectWinner: (vendor: QuotationVendor) => void onViewPriceAdjustment?: (vendor: QuotationVendor) => void + onViewItemDetails?: (vendor: QuotationVendor) => void onSendBidding?: (vendor: QuotationVendor) => void onUpdateParticipation?: (vendor: QuotationVendor, participated: boolean) => void } @@ -32,6 +33,7 @@ export function getBiddingDetailVendorColumns({ onDelete, onSelectWinner, onViewPriceAdjustment, + onViewItemDetails, onSendBidding, onUpdateParticipation }: GetVendorColumnsProps): ColumnDef[] { @@ -72,11 +74,24 @@ export function getBiddingDetailVendorColumns({ { accessorKey: 'quotationAmount', header: '견적금액', - cell: ({ row }) => ( -
- {row.original.quotationAmount ? Number(row.original.quotationAmount).toLocaleString() : '-'} {row.original.currency} -
- ), + cell: ({ row }) => { + const hasAmount = row.original.quotationAmount && Number(row.original.quotationAmount) > 0 + return ( +
+ {hasAmount ? ( + + ) : ( + - {row.original.currency} + )} +
+ ) + }, }, { accessorKey: 'biddingResult', @@ -84,7 +99,7 @@ export function getBiddingDetailVendorColumns({ cell: ({ row }) => { const isWinner = row.original.isWinner if (isWinner === null || isWinner === undefined) { - return
-
+ return
미정
} return ( @@ -158,12 +173,24 @@ export function getBiddingDetailVendorColumns({ 작업 - onEdit(vendor)}> + onEdit(vendor)} + disabled={vendor.isBiddingParticipated !== true} + > 발주비율 산정 + {vendor.isBiddingParticipated !== true && ( + (입찰참여 필요) + )} {vendor.status !== 'selected' && ( - onSelectWinner(vendor)}> + onSelectWinner(vendor)} + disabled={vendor.isBiddingParticipated !== true} + > 낙찰 선정 + {vendor.isBiddingParticipated !== true && ( + (입찰참여 필요) + )} )} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx index b10212ab..9a5408c2 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx @@ -112,6 +112,22 @@ export function BiddingDetailVendorEditDialog({ )} {/* 수정 가능한 필드들 */} + {vendor && vendor.isBiddingParticipated !== true && ( +
+
+ + + + 입찰 참여 안내 +
+

+ {vendor.isBiddingParticipated === null + ? '이 업체는 아직 입찰참여 여부가 결정되지 않았습니다. 입찰에 참여한 업체만 발주비율을 설정할 수 있습니다.' + : '이 업체는 입찰에 참여하지 않습니다. 발주비율을 설정할 수 없습니다.' + } +

+
+ )}
@@ -123,14 +139,23 @@ export function BiddingDetailVendorEditDialog({ value={formData.awardRatio} onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })} placeholder="발주비율을 입력하세요" + disabled={vendor?.isBiddingParticipated !== true} /> + {vendor?.isBiddingParticipated !== true && ( +

+ 입찰에 참여한 업체만 발주비율을 설정할 수 있습니다. +

+ )}
- diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index dd1ae94b..95f63ce9 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -1,6 +1,7 @@ 'use client' import * as React from 'react' +import { useSession } from 'next-auth/react' import { type DataTableAdvancedFilterField, type DataTableFilterField } from '@/types/table' import { useDataTable } from '@/hooks/use-data-table' import { DataTable } from '@/components/data-table/data-table' @@ -33,6 +34,7 @@ interface BiddingDetailVendorTableContentProps { onEdit?: (vendor: QuotationVendor) => void onDelete?: (vendor: QuotationVendor) => void onSelectWinner?: (vendor: QuotationVendor) => void + onViewItemDetails?: (vendor: QuotationVendor) => void } const filterFields: DataTableFilterField[] = [ @@ -97,10 +99,15 @@ export function BiddingDetailVendorTableContent({ onOpenAwardDialog, onEdit, onDelete, - onSelectWinner + onSelectWinner, + onViewItemDetails }: BiddingDetailVendorTableContentProps) { + const { data: session } = useSession() const { toast } = useToast() const [isPending, startTransition] = useTransition() + + // 세션에서 사용자 ID 가져오기 + const userId = session?.user?.id || '' const [selectedVendor, setSelectedVendor] = React.useState(null) const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false) @@ -145,7 +152,7 @@ export function BiddingDetailVendorTableContent({ const result = selectWinnerSchema.safeParse({ biddingId, vendorId: vendor.id, - awardRatio: vendor.awardRatio, + awardRatio: vendor.awardRatio || 0, }) if (!result.success) { @@ -157,7 +164,7 @@ export function BiddingDetailVendorTableContent({ return } - const response = await selectWinner(biddingId, vendor.id, vendor.awardRatio, 'current-user') + const response = await selectWinner(biddingId, vendor.id, vendor.awardRatio || 0, userId) if (response.success) { toast({ @@ -209,9 +216,10 @@ export function BiddingDetailVendorTableContent({ onEdit: onEdit || handleEdit, onDelete: onDelete || handleDelete, onSelectWinner: onSelectWinner || handleSelectWinner, - onViewPriceAdjustment: handleViewPriceAdjustment + onViewPriceAdjustment: handleViewPriceAdjustment, + onViewItemDetails: onViewItemDetails }), - [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner, handleViewPriceAdjustment] + [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner, handleViewPriceAdjustment, onViewItemDetails] ) const { table } = useDataTable({ @@ -241,9 +249,9 @@ export function BiddingDetailVendorTableContent({ table={table} biddingId={biddingId} bidding={bidding} + userId={userId} onOpenItemsDialog={onOpenItemsDialog} onOpenTargetPriceDialog={onOpenTargetPriceDialog} - onOpenSelectionReasonDialog={onOpenSelectionReasonDialog} onOpenAwardDialog={() => setIsAwardDialogOpen(true)} 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 8cdec191..64c31633 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -15,6 +15,7 @@ interface BiddingDetailVendorToolbarActionsProps { table: Table biddingId: number bidding: Bidding + userId: string onOpenItemsDialog: () => void onOpenTargetPriceDialog: () => void onOpenAwardDialog: () => void @@ -25,6 +26,7 @@ export function BiddingDetailVendorToolbarActions({ table, biddingId, bidding, + userId, onOpenItemsDialog, onOpenTargetPriceDialog, onOpenAwardDialog, @@ -41,17 +43,17 @@ export function BiddingDetailVendorToolbarActions({ const handleRegister = () => { startTransition(async () => { - const result = await registerBidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID + const result = await registerBidding(bidding.id, userId) if (result.success) { toast({ - title: '성공', + title: result.message, description: result.message, }) router.refresh() } else { toast({ - title: '오류', + title: result.error, description: result.error, variant: 'destructive', }) @@ -61,17 +63,17 @@ export function BiddingDetailVendorToolbarActions({ const handleMarkAsDisposal = () => { startTransition(async () => { - const result = await markAsDisposal(bidding.id, 'current-user') // TODO: 실제 사용자 ID + const result = await markAsDisposal(bidding.id, userId) if (result.success) { toast({ - title: '성공', + title: result.message, description: result.message, }) router.refresh() } else { toast({ - title: '오류', + title: result.error, description: result.error, variant: 'destructive', }) @@ -81,18 +83,18 @@ export function BiddingDetailVendorToolbarActions({ const handleCreateRebidding = () => { startTransition(async () => { - const result = await createRebidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID + const result = await createRebidding(bidding.id, userId) if (result.success) { toast({ - title: '성공', + title: result.message, description: result.message, }) router.refresh() onSuccess() } else { toast({ - title: '오류', + title: result.error, description: result.error, variant: 'destructive', }) @@ -104,7 +106,7 @@ export function BiddingDetailVendorToolbarActions({ <>
{/* 상태별 액션 버튼 */} - {bidding.status === 'bidding_generated' && ( + {bidding.status === 'set_target_price' && ( + */}