From 25749225689c3934bc10ad1e8285e13020b61282 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 4 Dec 2025 09:04:09 +0000 Subject: (최겸)구매 입찰, 계약 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bidding/manage/bidding-companies-editor.tsx | 262 ++++++++++++++++++++- components/bidding/manage/bidding-items-editor.tsx | 181 +++++++++++++- .../bidding/manage/create-pre-quote-rfq-dialog.tsx | 38 ++- 3 files changed, 467 insertions(+), 14 deletions(-) (limited to 'components/bidding') diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index 6634f528..4c3e6bbc 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Building, User, Plus, Trash2 } from 'lucide-react' +import { Building, User, Plus, Trash2, Users } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -11,7 +11,9 @@ import { createBiddingCompanyContact, deleteBiddingCompanyContact, getVendorContactsByVendorId, - updateBiddingCompanyPriceAdjustmentQuestion + updateBiddingCompanyPriceAdjustmentQuestion, + getBiddingCompaniesByBidPicId, + addBiddingCompanyFromOtherBidding } from '@/lib/bidding/service' import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service' import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' @@ -36,6 +38,7 @@ import { } from '@/components/ui/table' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { PurchaseGroupCodeSelector, PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector' interface QuotationVendor { id: number // biddingCompanies.id @@ -102,6 +105,26 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false) const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState(null) + // 협력사 멀티 선택 다이얼로그 + const [multiSelectDialogOpen, setMultiSelectDialogOpen] = React.useState(false) + const [selectedBidPic, setSelectedBidPic] = React.useState(undefined) + const [biddingCompaniesList, setBiddingCompaniesList] = React.useState>([]) + const [isLoadingBiddingCompanies, setIsLoadingBiddingCompanies] = React.useState(false) + const [selectedBiddingCompany, setSelectedBiddingCompany] = React.useState<{ + biddingId: number + companyId: number + } | null>(null) + const [selectedBiddingCompanyContacts, setSelectedBiddingCompanyContacts] = React.useState([]) + const [isLoadingCompanyContacts, setIsLoadingCompanyContacts] = React.useState(false) + // 업체 목록 다시 로딩 함수 const reloadVendors = React.useCallback(async () => { try { @@ -494,10 +517,16 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC

{!readonly && ( - +
+ + +
)} @@ -740,6 +769,227 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC + {/* 협력사 멀티 선택 다이얼로그 */} + + + + 참여협력사 선택 + + 입찰담당자를 선택하여 해당 담당자의 입찰 업체를 조회하고 선택할 수 있습니다. + + + +
+ {/* 입찰담당자 선택 */} +
+ + { + setSelectedBidPic(code) + if (code.user?.id) { + setIsLoadingBiddingCompanies(true) + try { + const result = await getBiddingCompaniesByBidPicId(code.user.id) + if (result.success && result.data) { + setBiddingCompaniesList(result.data) + } else { + toast.error(result.error || '입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } + } catch (error) { + console.error('Failed to load bidding companies:', error) + toast.error('입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } finally { + setIsLoadingBiddingCompanies(false) + } + } + }} + placeholder="입찰담당자 선택" + disabled={readonly} + /> +
+ + {/* 입찰 업체 목록 */} + {isLoadingBiddingCompanies ? ( +
+ + 입찰 업체를 불러오는 중... +
+ ) : biddingCompaniesList.length === 0 && selectedBidPic ? ( +
+ 해당 입찰담당자의 입찰 업체가 없습니다. +
+ ) : biddingCompaniesList.length > 0 ? ( +
+ + + + 선택 + 입찰번호 + 입찰명 + 협력사코드 + 협력사명 + 입찰 업데이트일 + + + + {biddingCompaniesList.map((company) => { + const isSelected = selectedBiddingCompany?.biddingId === company.biddingId && + selectedBiddingCompany?.companyId === company.companyId + return ( + { + if (isSelected) { + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + return + } + setSelectedBiddingCompany({ + biddingId: company.biddingId, + companyId: company.companyId + }) + setIsLoadingCompanyContacts(true) + try { + const contactsResult = await getBiddingCompanyContacts(company.biddingId, company.companyId) + if (contactsResult.success && contactsResult.data) { + setSelectedBiddingCompanyContacts(contactsResult.data) + } else { + setSelectedBiddingCompanyContacts([]) + } + } catch (error) { + console.error('Failed to load company contacts:', error) + setSelectedBiddingCompanyContacts([]) + } finally { + setIsLoadingCompanyContacts(false) + } + }} + > + e.stopPropagation()}> + { + // 클릭 이벤트는 TableRow의 onClick에서 처리 + }} + disabled={readonly} + /> + + {company.biddingNumber} + {company.biddingTitle} + {company.vendorCode} + {company.vendorName} + + {company.updatedAt ? new Date(company.updatedAt).toLocaleDateString('ko-KR') : '-'} + + + ) + })} + +
+ + {/* 선택한 입찰 업체의 담당자 정보 */} + {selectedBiddingCompany !== null && ( +
+

담당자 정보

+ {isLoadingCompanyContacts ? ( +
+ + 담당자 정보를 불러오는 중... +
+ ) : selectedBiddingCompanyContacts.length === 0 ? ( +
등록된 담당자가 없습니다.
+ ) : ( +
+ {selectedBiddingCompanyContacts.map((contact) => ( +
+ {contact.contactName} + {contact.contactEmail} + {contact.contactNumber && ( + {contact.contactNumber} + )} +
+ ))} +
+ )} +
+ )} +
+ ) : null} +
+ + + + + +
+
+ {/* 벤더 담당자에서 추가 다이얼로그 */} diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index 90e512d2..452cdc3c 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react' +import { Package, Plus, Trash2, Save, RefreshCw, FileText, FileSpreadsheet, Upload } from 'lucide-react' import { getPRItemsForBidding } from '@/lib/bidding/detail/service' import { updatePrItem } from '@/lib/bidding/detail/service' import { toast } from 'sonner' @@ -26,7 +26,7 @@ import { CostCenterSingleSelector } from '@/components/common/selectors/cost-cen import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector' // PR 아이템 정보 타입 (create-bidding-dialog와 동일) -interface PRItemInfo { +export interface PRItemInfo { id: number // 실제 DB ID prNumber?: string | null projectId?: number | null @@ -84,6 +84,16 @@ import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog' import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' +import { exportBiddingItemsToExcel } from '@/lib/bidding/manage/export-bidding-items-to-excel' +import { importBiddingItemsFromExcel } from '@/lib/bidding/manage/import-bidding-items-from-excel' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItemsEditorProps) { const { data: session } = useSession() @@ -114,6 +124,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems isPriceAdjustmentApplicable?: boolean | null sparePartOptions?: string | null } | null>(null) + const [importDialogOpen, setImportDialogOpen] = React.useState(false) + const [importFile, setImportFile] = React.useState(null) + const [importErrors, setImportErrors] = React.useState([]) + const [isImporting, setIsImporting] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) // 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드 React.useEffect(() => { @@ -492,7 +507,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems materialGroupInfo: null, materialNumber: null, materialInfo: null, - priceUnit: 1, + priceUnit: '1', purchaseUnit: 'EA', materialWeight: null, wbsCode: null, @@ -644,6 +659,76 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems const totals = calculateTotals() + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + if (items.length === 0) { + toast.error('내보낼 품목이 없습니다.') + return + } + + try { + setIsExporting(true) + await exportBiddingItemsToExcel(items, { + filename: `입찰품목목록_${biddingId}`, + }) + toast.success('Excel 파일이 다운로드되었습니다.') + } catch (error) { + console.error('Excel export error:', error) + toast.error('Excel 내보내기 중 오류가 발생했습니다.') + } finally { + setIsExporting(false) + } + }, [items, biddingId]) + + // Excel 가져오기 핸들러 + const handleImportFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) { + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') + return + } + setImportFile(file) + setImportErrors([]) + } + } + + const handleImport = async () => { + if (!importFile) return + + setIsImporting(true) + setImportErrors([]) + + try { + const result = await importBiddingItemsFromExcel(importFile) + + if (result.errors.length > 0) { + setImportErrors(result.errors) + toast.warning( + `${result.items.length}개의 품목을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.` + ) + return + } + + if (result.items.length === 0) { + toast.error('가져올 품목이 없습니다.') + return + } + + // 기존 아이템에 추가 + setItems((prev) => [...prev, ...result.items]) + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + toast.success(`${result.items.length}개의 품목이 추가되었습니다.`) + } catch (error) { + console.error('Excel import error:', error) + toast.error('Excel 가져오기 중 오류가 발생했습니다.') + } finally { + setIsImporting(false) + } + } + if (isLoading) { return (
@@ -1372,6 +1457,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems 사전견적 + + + + + +
) diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx index de3c19ff..1ab7a40f 100644 --- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx +++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx @@ -465,7 +465,7 @@ export function CreatePreQuoteRfqDialog({ )} > {field.value ? ( - format(field.value, "yyyy-MM-dd") + format(field.value, "yyyy-MM-dd HH:mm") ) : ( 제출마감일을 선택하세요 (선택) )} @@ -477,12 +477,40 @@ export function CreatePreQuoteRfqDialog({ - date < new Date() || date < new Date("1900-01-01") - } + onSelect={(date) => { + if (!date) { + field.onChange(undefined) + return + } + const newDate = new Date(date) + if (field.value) { + newDate.setHours(field.value.getHours(), field.value.getMinutes()) + } else { + newDate.setHours(0, 0, 0, 0) + } + field.onChange(newDate) + }} + disabled={(date) => { + const today = new Date() + today.setHours(0, 0, 0, 0) + return date < today || date < new Date("1900-01-01") + }} initialFocus /> +
+ { + if (field.value) { + const [hours, minutes] = e.target.value.split(':').map(Number) + const newDate = new Date(field.value) + newDate.setHours(hours, minutes) + field.onChange(newDate) + } + }} + /> +
-- cgit v1.2.3 From 93b6b8868d409c7f6c9d9222b93750848caaedde Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 5 Dec 2025 03:28:04 +0000 Subject: (최겸) 구매 입찰 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bidding/create/bidding-create-dialog.tsx | 51 ++++++++---- .../bidding/manage/bidding-basic-info-editor.tsx | 2 - .../bidding/manage/bidding-companies-editor.tsx | 17 +++- .../manage/bidding-detail-vendor-create-dialog.tsx | 15 +++- .../bidding/manage/create-pre-quote-rfq-dialog.tsx | 90 ++++++++-------------- lib/bidding/actions.ts | 2 +- lib/bidding/approval-actions.ts | 16 ++-- lib/bidding/detail/service.ts | 14 +++- lib/bidding/handlers.ts | 21 ++++- .../manage/import-bidding-items-from-excel.ts | 4 +- lib/bidding/pre-quote/service.ts | 10 ++- lib/bidding/service.ts | 61 ++++++++++++--- lib/bidding/validation.ts | 2 - .../vendor/partners-bidding-attendance-dialog.tsx | 1 - lib/bidding/vendor/partners-bidding-detail.tsx | 7 +- lib/bidding/vendor/partners-bidding-list.tsx | 1 - 16 files changed, 196 insertions(+), 118 deletions(-) (limited to 'components/bidding') diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx index b3972e11..af33f1f6 100644 --- a/components/bidding/create/bidding-create-dialog.tsx +++ b/components/bidding/create/bidding-create-dialog.tsx @@ -63,7 +63,7 @@ import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchas import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager' import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service' import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service' -import { createBidding } from '@/lib/bidding/service' +import { createBidding, getUserDetails } from '@/lib/bidding/service' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' @@ -97,13 +97,6 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp sparePartOptions: '', }) - // 구매요청자 정보 (현재 사용자) - // React.useEffect(() => { - // // 실제로는 현재 로그인한 사용자의 정보를 가져와야 함 - // // 임시로 기본값 설정 - // form.setValue('requesterName', '김두진') // 실제로는 API에서 가져와야 함 - // }, [form]) - const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState([]) const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState([]) @@ -164,13 +157,41 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp React.useEffect(() => { if (isOpen) { - if (userId && session?.user?.name) { - // 현재 사용자의 정보를 임시로 입찰담당자로 설정 - form.setValue('bidPicName', session.user.name) - form.setValue('bidPicId', userId) - // userCode는 현재 세션에 없으므로 이름으로 설정 (실제로는 API에서 가져와야 함) - // form.setValue('bidPicCode', session.user.name) + const initUser = async () => { + if (userId) { + try { + const user = await getUserDetails(userId) + if (user) { + // 현재 사용자의 정보를 입찰담당자로 설정 + form.setValue('bidPicName', user.name) + form.setValue('bidPicId', user.id) + form.setValue('bidPicCode', user.userCode || '') + + // 담당자 selector 상태 업데이트 + setSelectedBidPic({ + PURCHASE_GROUP_CODE: user.userCode || '', + DISPLAY_NAME: user.name, + EMPLOYEE_NUMBER: user.employeeNumber || '', + user: { + id: user.id, + name: user.name, + email: '', + employeeNumber: user.employeeNumber + } + } as any) + } + } catch (error) { + console.error('Failed to fetch user details:', error) + // 실패 시 세션 정보로 폴백 + if (session?.user?.name) { + form.setValue('bidPicName', session.user.name) + form.setValue('bidPicId', userId) + } + } + } } + initUser() + loadPaymentTerms() loadIncoterms() loadShippingPlaces() @@ -181,7 +202,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp form.setValue('biddingConditions.taxConditions', 'V1') } } - }, [isOpen, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces, form]) + }, [isOpen, userId, session, form, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]) // SHI용 파일 첨부 핸들러 const handleShiFileUpload = (event: React.ChangeEvent) => { diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx index 27a2c097..13c58311 100644 --- a/components/bidding/manage/bidding-basic-info-editor.tsx +++ b/components/bidding/manage/bidding-basic-info-editor.tsx @@ -88,7 +88,6 @@ interface BiddingBasicInfo { contractEndDate?: string submissionStartDate?: string submissionEndDate?: string - evaluationDate?: string hasSpecificationMeeting?: boolean hasPrDocument?: boolean currency?: string @@ -252,7 +251,6 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB contractEndDate: formatDate(bidding.contractEndDate), submissionStartDate: formatDateTime(bidding.submissionStartDate), submissionEndDate: formatDateTime(bidding.submissionEndDate), - evaluationDate: formatDateTime(bidding.evaluationDate), hasSpecificationMeeting: bidding.hasSpecificationMeeting || false, hasPrDocument: bidding.hasPrDocument || false, currency: bidding.currency || 'KRW', diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index 4c3e6bbc..9bfea90e 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -566,7 +566,22 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC {vendor.vendorName} {vendor.vendorCode} - {vendor.businessSize || '-'} + + {(() => { + switch (vendor.businessSize) { + case 'A': + return '대기업'; + case 'B': + return '중견기업'; + case 'C': + return '중소기업'; + case 'D': + return '소기업'; + default: + return '-'; + } + })()} + {vendor.companyId && vendorFirstContacts.has(vendor.companyId) ? vendorFirstContacts.get(vendor.companyId)!.contactName diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx index 0dd9f0eb..489f104d 100644 --- a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx +++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx @@ -408,7 +408,20 @@ export function BiddingDetailVendorCreateDialog({ 연동제 적용요건 문의 - 기업규모: {businessSizeMap[item.vendor.id] || '미정'} + 기업규모: {(() => { + switch (businessSizeMap[item.vendor.id]) { + case 'A': + return '대기업'; + case 'B': + return '중견기업'; + case 'C': + return '중소기업'; + case 'D': + return '소기업'; + default: + return '-'; + } + })()} diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx index 1ab7a40f..b0cecc25 100644 --- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx +++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx @@ -26,13 +26,6 @@ import { FormMessage, FormDescription, } from "@/components/ui/form" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { @@ -41,20 +34,15 @@ import { PopoverTrigger, } from "@/components/ui/popover" import { Calendar } from "@/components/ui/calendar" -import { Badge } from "@/components/ui/badge" import { cn } from "@/lib/utils" import { toast } from "sonner" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { createPreQuoteRfqAction } from "@/lib/bidding/pre-quote/service" -import { previewGeneralRfqCode } from "@/lib/rfq-last/service" -import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single" -import { MaterialSearchItem } from "@/lib/material/material-group-service" -import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single" -import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service" import { PurchaseGroupCodeSelector } from "@/components/common/selectors/purchase-group-code/purchase-group-code-selector" import type { PurchaseGroupCodeWithUser } from "@/components/common/selectors/purchase-group-code" import { getBiddingById } from "@/lib/bidding/service" +import { getProjectIdByCodeAndName } from "@/lib/bidding/manage/project-utils" // 아이템 스키마 const itemSchema = z.object({ @@ -64,6 +52,8 @@ const itemSchema = z.object({ materialName: z.string().optional(), quantity: z.number().min(1, "수량은 1 이상이어야 합니다"), uom: z.string().min(1, "단위를 입력해주세요"), + totalWeight: z.union([z.number(), z.string(), z.null()]).optional(), // 중량 추가 + weightUnit: z.string().optional().nullable(), // 중량단위 추가 remark: z.string().optional(), }) @@ -125,8 +115,6 @@ export function CreatePreQuoteRfqDialog({ onSuccess }: CreatePreQuoteRfqDialogProps) { const [isLoading, setIsLoading] = React.useState(false) - const [previewCode, setPreviewCode] = React.useState("") - const [isLoadingPreview, setIsLoadingPreview] = React.useState(false) const [selectedBidPic, setSelectedBidPic] = React.useState(undefined) const { data: session } = useSession() @@ -143,6 +131,8 @@ export function CreatePreQuoteRfqDialog({ materialName: item.materialInfo || "", quantity: item.quantity ? parseFloat(item.quantity) : 1, uom: item.quantityUnit || item.weightUnit || "EA", + totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null, + weightUnit: item.weightUnit || null, remark: "", })) }, [biddingItems]) @@ -164,6 +154,8 @@ export function CreatePreQuoteRfqDialog({ materialName: "", quantity: 1, uom: "", + totalWeight: null, + weightUnit: null, remark: "", }, ], @@ -231,6 +223,14 @@ export function CreatePreQuoteRfqDialog({ const pName = bidding.projectName || ""; setProjectInfo(pCode && pName ? `${pCode} - ${pName}` : pCode || pName || ""); + // 프로젝트 ID 조회 + if (pCode && pName) { + const fetchedProjectId = await getProjectIdByCodeAndName(pCode, pName) + if (fetchedProjectId) { + form.setValue("projectId", fetchedProjectId) + } + } + // 폼 값 설정 form.setValue("rfqTitle", rfqTitle); form.setValue("rfqType", "pre_bidding"); // 기본값 설정 @@ -264,36 +264,15 @@ export function CreatePreQuoteRfqDialog({ materialName: "", quantity: 1, uom: "", + totalWeight: null, + weightUnit: null, remark: "", }, ], }) - setPreviewCode("") } }, [open, initialItems, form, selectedBidPic, biddingId]) - // 견적담당자 선택 시 RFQ 코드 미리보기 생성 - React.useEffect(() => { - if (!selectedBidPic?.user?.id) { - setPreviewCode("") - return - } - - // 즉시 실행 함수 패턴 사용 - (async () => { - setIsLoadingPreview(true) - try { - const code = await previewGeneralRfqCode(selectedBidPic.user!.id) - setPreviewCode(code) - } catch (error) { - console.error("코드 미리보기 오류:", error) - setPreviewCode("") - } finally { - setIsLoadingPreview(false) - } - })() - }, [selectedBidPic]) - // 견적 종류 변경 const handleRfqTypeChange = (value: string) => { form.setValue("rfqType", value) @@ -315,12 +294,13 @@ export function CreatePreQuoteRfqDialog({ materialName: "", quantity: 1, uom: "", + totalWeight: null, + weightUnit: null, remark: "", }, ], }) setSelectedBidPic(undefined) - setPreviewCode("") onOpenChange(false) } @@ -350,15 +330,17 @@ export function CreatePreQuoteRfqDialog({ biddingNumber: data.biddingNumber, // 추가 contractStartDate: data.contractStartDate, // 추가 contractEndDate: data.contractEndDate, // 추가 - items: data.items as Array<{ - itemCode: string; - itemName: string; - materialCode?: string; - materialName?: string; - quantity: number; - uom: string; - remark?: string; - }>, + items: data.items.map(item => ({ + itemCode: item.itemCode || "", + itemName: item.itemName || "", + materialCode: item.materialCode, + materialName: item.materialName, + quantity: item.quantity, + uom: item.uom, + totalWeight: item.totalWeight, + weightUnit: item.weightUnit, + remark: item.remark, + })), biddingConditions: biddingConditions || undefined, createdBy: userId, updatedBy: userId, @@ -590,17 +572,7 @@ export function CreatePreQuoteRfqDialog({ )} /> - {/* RFQ 코드 미리보기 */} - {previewCode && ( -
- - 예상 RFQ 코드: {previewCode} - - {isLoadingPreview && ( - - )} -
- )} + {/* 계약기간 */}
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts index cc246ee7..6bedbab5 100644 --- a/lib/bidding/actions.ts +++ b/lib/bidding/actions.ts @@ -652,7 +652,7 @@ export async function cancelDisposalAction( } // 사용자 이름 조회 헬퍼 함수 -async function getUserNameById(userId: string): Promise { +export async function getUserNameById(userId: string): Promise { try { const user = await db .select({ name: users.name }) diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts index 0fb16439..b4f6f297 100644 --- a/lib/bidding/approval-actions.ts +++ b/lib/bidding/approval-actions.ts @@ -266,12 +266,14 @@ export async function requestBiddingInvitationWithApproval(data: { const { default: db } = await import('@/db/db'); const { biddings, biddingCompanies, prItemsForBidding } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); - + const { getUserNameById } = await import('@/lib/bidding/actions'); + const userName = await getUserNameById(data.currentUser.id.toString()); + await db .update(biddings) .set({ status: 'approval_pending', // 결재 진행중 상태 - // updatedBy: String(data.currentUser.id), // 기존 등록자 유지를 위해 주석 처리 + updatedBy: userName, updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); @@ -465,6 +467,7 @@ export async function requestBiddingClosureWithApproval(data: { const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); + const { getUserNameById } = await import('@/lib/bidding/actions'); // 유찰상태인지 확인 const biddingResult = await db @@ -487,12 +490,12 @@ export async function requestBiddingClosureWithApproval(data: { // 3. 입찰 상태를 결재 진행중으로 변경 debugLog('[BiddingClosureApproval] 입찰 상태 변경 시작'); - + const userName = await getUserNameById(data.currentUser.id.toString()); await db .update(biddings) .set({ status: 'approval_pending', // 폐찰 결재 진행중 상태 - // updatedBy: Number(data.currentUser.id), // 기존 등록자 유지를 위해 주석 처리 + updatedBy: userName, updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); @@ -693,12 +696,13 @@ export async function requestBiddingAwardWithApproval(data: { const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); - + const { getUserNameById } = await import('@/lib/bidding/actions'); + const userName = await getUserNameById(data.currentUser.id.toString()); await db .update(biddings) .set({ status: 'approval_pending', // 낙찰 결재 진행중 상태 - // updatedBy: Number(data.currentUser.id), // 기존 등록자 유지를 위해 주석 처리 + updatedBy: userName, updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 68c55fb0..17ea8f28 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -1288,10 +1288,14 @@ export async function getAwardedCompanies(biddingId: number) { companyId: biddingCompanies.companyId, companyName: vendors.vendorName, finalQuoteAmount: biddingCompanies.finalQuoteAmount, - awardRatio: biddingCompanies.awardRatio + awardRatio: biddingCompanies.awardRatio, + vendorCode: vendors.vendorCode, + companySize: vendors.businessSize, + targetPrice: biddings.targetPrice }) .from(biddingCompanies) .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) .where(and( eq(biddingCompanies.biddingId, biddingId), eq(biddingCompanies.isWinner, true) @@ -1301,7 +1305,10 @@ export async function getAwardedCompanies(biddingId: number) { companyId: company.companyId, companyName: company.companyName, finalQuoteAmount: parseFloat(company.finalQuoteAmount?.toString() || '0'), - awardRatio: parseFloat(company.awardRatio?.toString() || '0') + awardRatio: parseFloat(company.awardRatio?.toString() || '0'), + vendorCode: company.vendorCode, + companySize: company.companySize, + targetPrice: company.targetPrice ? parseFloat(company.targetPrice.toString()) : 0 })) } catch (error) { console.error('Failed to get awarded companies:', error) @@ -1330,7 +1337,7 @@ async function updateBiddingAmounts(biddingId: number) { .set({ targetPrice: totalTargetAmount.toString(), budget: totalBudgetAmount.toString(), - finalBidPrice: totalActualAmount.toString(), + actualPrice: totalActualAmount.toString(), updatedAt: new Date() }) .where(eq(biddings.id, biddingId)) @@ -1745,7 +1752,6 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId: biddingRegistrationDate: biddings.biddingRegistrationDate, submissionStartDate: biddings.submissionStartDate, submissionEndDate: biddings.submissionEndDate, - evaluationDate: biddings.evaluationDate, // 가격 정보 currency: biddings.currency, diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts index d56a083a..03a85bb6 100644 --- a/lib/bidding/handlers.ts +++ b/lib/bidding/handlers.ts @@ -422,12 +422,13 @@ export async function requestBiddingClosureInternal(payload: { const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); - + const { getUserNameById } = await import('@/lib/bidding/actions'); + const userName = await getUserNameById(payload.currentUserId.toString()); await db .update(biddings) .set({ status: 'bid_closure', - updatedBy: payload.currentUserId.toString(), + updatedBy: userName, updatedAt: new Date(), remarks: payload.description, // 폐찰 사유를 remarks에 저장 }) @@ -614,6 +615,15 @@ export async function mapBiddingAwardToTemplateVariables(payload: { biddingId: number; selectionReason: string; requestedAt: Date; + awardedCompanies?: Array<{ + companyId: number; + companyName: string | null; + finalQuoteAmount: number; + awardRatio: number; + vendorCode?: string | null; + companySize?: string | null; + targetPrice?: number | null; + }>; }): Promise> { const { biddingId, selectionReason, requestedAt } = payload; @@ -649,8 +659,11 @@ export async function mapBiddingAwardToTemplateVariables(payload: { const bidding = biddingInfo[0]; // 2. 낙찰된 업체 정보 조회 - const { getAwardedCompanies } = await import('@/lib/bidding/detail/service'); - const awardedCompanies = await getAwardedCompanies(biddingId); + let awardedCompanies = payload.awardedCompanies; + if (!awardedCompanies) { + const { getAwardedCompanies } = await import('@/lib/bidding/detail/service'); + awardedCompanies = await getAwardedCompanies(biddingId); + } // 3. 입찰 대상 자재 정보 조회 const biddingItemsInfo = await db diff --git a/lib/bidding/manage/import-bidding-items-from-excel.ts b/lib/bidding/manage/import-bidding-items-from-excel.ts index 2e0dfe33..fe5b17a9 100644 --- a/lib/bidding/manage/import-bidding-items-from-excel.ts +++ b/lib/bidding/manage/import-bidding-items-from-excel.ts @@ -1,6 +1,7 @@ import ExcelJS from "exceljs" import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor" import { getProjectIdByCodeAndName } from "./project-utils" +import { decryptWithServerAction } from "@/components/drm/drmUtils" export interface ImportBiddingItemsResult { success: boolean @@ -19,7 +20,8 @@ export async function importBiddingItemsFromExcel( try { const workbook = new ExcelJS.Workbook() - const arrayBuffer = await file.arrayBuffer() + // DRM 해제 후 ArrayBuffer 획득 (DRM 서버 미연결 시 원본 반환) + const arrayBuffer = await decryptWithServerAction(file) await workbook.xlsx.load(arrayBuffer) const worksheet = workbook.worksheets[0] diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index e1152abe..6fef228c 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -859,8 +859,8 @@ export async function getSelectedVendorsForBidding(biddingId: number) { interface CreatePreQuoteRfqInput { rfqType: string; rfqTitle: string; - dueDate: Date; - picUserId: number; + dueDate?: Date; + picUserId: number | string | undefined; projectId?: number; remark?: string; biddingNumber?: string; @@ -875,6 +875,8 @@ interface CreatePreQuoteRfqInput { remark?: string; materialCode?: string; materialName?: string; + totalWeight?: number | string | null; // 중량 추가 + weightUnit?: string | null; // 중량단위 추가 }>; biddingConditions?: { paymentTerms?: string | null @@ -976,6 +978,10 @@ export async function createPreQuoteRfqAction(input: CreatePreQuoteRfqInput) { quantity: item.quantity, // 수량 uom: item.uom, // 단위 + // 중량 정보 + grossWeight: item.totalWeight ? (typeof item.totalWeight === 'string' ? parseFloat(item.totalWeight) : item.totalWeight) : null, + gwUom: item.weightUnit || null, + majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정 remark: item.remark || null, // 비고 })); diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 77a0b1b4..76cd31f7 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -61,6 +61,27 @@ export async function getUserCodeByEmail(email: string): Promise } } +// 사용자 ID로 상세 정보 조회 (이름, 코드 등) +export async function getUserDetails(userId: number) { + try { + const user = await db + .select({ + id: users.id, + name: users.name, + userCode: users.userCode, + employeeNumber: users.employeeNumber + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1) + + return user[0] || null + } catch (error) { + console.error('Failed to get user details:', error) + return null + } +} + // userId를 user.name으로 변환하는 유틸리티 함수 async function getUserNameById(userId: string): Promise { try { @@ -421,9 +442,10 @@ export async function getBiddings(input: GetBiddingsSchema) { // 메타 정보 remarks: biddings.remarks, updatedAt: biddings.updatedAt, - updatedBy: biddings.updatedBy, + updatedBy: users.name, }) .from(biddings) + .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`) .where(finalWhere) .orderBy(...orderByColumns) .limit(input.perPage) @@ -874,7 +896,6 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { biddingRegistrationDate: new Date(), submissionStartDate: parseDate(input.submissionStartDate), submissionEndDate: parseDate(input.submissionEndDate), - evaluationDate: parseDate(input.evaluationDate), hasSpecificationMeeting: input.hasSpecificationMeeting || false, hasPrDocument: input.hasPrDocument || false, @@ -913,6 +934,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { await tx.insert(biddingNoticeTemplate).values({ biddingId, title: input.title + ' 입찰공고', + type: input.noticeType || 'standard', content: input.content || standardContent, isTemplate: false, }) @@ -1723,7 +1745,6 @@ export async function updateBiddingBasicInfo( contractEndDate?: string submissionStartDate?: string submissionEndDate?: string - evaluationDate?: string hasSpecificationMeeting?: boolean hasPrDocument?: boolean currency?: string @@ -1781,9 +1802,23 @@ export async function updateBiddingBasicInfo( // 정의된 필드들만 업데이트 if (updates.title !== undefined) updateData.title = updates.title if (updates.description !== undefined) updateData.description = updates.description - if (updates.content !== undefined) updateData.content = updates.content + // content는 bidding 테이블에 컬럼이 없음, notice content는 별도로 저장해야 함 + // if (updates.content !== undefined) updateData.content = updates.content if (updates.noticeType !== undefined) updateData.noticeType = updates.noticeType if (updates.contractType !== undefined) updateData.contractType = updates.contractType + + // 입찰공고 내용 저장 + if (updates.content !== undefined) { + try { + await saveBiddingNotice(biddingId, { + title: (updates.title || '') + ' 입찰공고', // 제목이 없으면 기존 제목을 가져오거나 해야하는데, 여기서는 업데이트된 제목 사용 + content: updates.content + }) + } catch (e) { + console.error('Failed to save bidding notice content:', e) + // 공고 저장 실패는 전체 업데이트 실패로 처리하지 않음 (로그만 남김) + } + } if (updates.biddingType !== undefined) updateData.biddingType = updates.biddingType if (updates.biddingTypeCustom !== undefined) updateData.biddingTypeCustom = updates.biddingTypeCustom if (updates.awardCount !== undefined) updateData.awardCount = updates.awardCount @@ -1795,7 +1830,6 @@ export async function updateBiddingBasicInfo( if (updates.contractEndDate !== undefined) updateData.contractEndDate = parseDate(updates.contractEndDate) if (updates.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(updates.submissionStartDate) if (updates.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(updates.submissionEndDate) - if (updates.evaluationDate !== undefined) updateData.evaluationDate = parseDate(updates.evaluationDate) if (updates.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = updates.hasSpecificationMeeting if (updates.hasPrDocument !== undefined) updateData.hasPrDocument = updates.hasPrDocument if (updates.currency !== undefined) updateData.currency = updates.currency @@ -2889,7 +2923,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u let currentRound = match ? parseInt(match[1]) : 1 if (currentRound >= 3) { - // -03 이상이면 새로운 번호 생성 + // -03 이상이면 재입찰이며, 새로운 번호 생성 newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx) // 새로 생성한 입찰번호를 원입찰번호로 셋팅 originalBiddingNumber = newBiddingNumber.split('-')[0] @@ -2913,13 +2947,15 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u // 기본 정보 복제 projectName: existingBidding.projectName, + projectCode: existingBidding.projectCode, // 프로젝트 코드 복제 itemName: existingBidding.itemName, title: existingBidding.title, description: existingBidding.description, // 계약 정보 복제 contractType: existingBidding.contractType, - biddingType: existingBidding.biddingType, + noticeType: existingBidding.noticeType, // 공고타입 복제 + biddingType: existingBidding.biddingType, // 구매유형 복제 awardCount: existingBidding.awardCount, contractStartDate: existingBidding.contractStartDate, contractEndDate: existingBidding.contractEndDate, @@ -2929,7 +2965,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u biddingRegistrationDate: new Date(), submissionStartDate: null, submissionEndDate: null, - evaluationDate: null, // 사양설명회 hasSpecificationMeeting: existingBidding.hasSpecificationMeeting, @@ -2939,6 +2974,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u budget: existingBidding.budget, targetPrice: existingBidding.targetPrice, targetPriceCalculationCriteria: existingBidding.targetPriceCalculationCriteria, + actualPrice: existingBidding.actualPrice, finalBidPrice: null, // 최종입찰가는 초기화 // PR 정보 복제 @@ -3194,8 +3230,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u .from(biddingDocuments) .where(and( eq(biddingDocuments.biddingId, biddingId), - // PR 아이템에 연결된 첨부파일은 제외 (SHI용과 협력업체용만 복제) - isNull(biddingDocuments.prItemId), // SHI용(evaluation_doc) 또는 협력업체용(company_proposal) 문서만 복제 or( eq(biddingDocuments.documentType, 'evaluation_doc'), @@ -3266,6 +3300,8 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u } revalidatePath('/bid-receive') + revalidatePath('/evcp/bid-receive') + revalidatePath('/evcp/bid') revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신 revalidatePath(`/bid-receive/${newBidding.id}`) @@ -3825,7 +3861,7 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { // 유찰 정보 (업데이트 일시를 유찰일로 사용) disposalDate: biddings.updatedAt, // 유찰일 disposalUpdatedAt: biddings.updatedAt, // 폐찰수정일 - disposalUpdatedBy: biddings.updatedBy, // 폐찰수정자 + disposalUpdatedBy: users.name, // 폐찰수정자 // 폐찰 정보 closureReason: biddings.description, // 폐찰사유 @@ -3840,9 +3876,10 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { createdBy: biddings.createdBy, createdAt: biddings.createdAt, updatedAt: biddings.updatedAt, - updatedBy: biddings.updatedBy, + updatedBy: users.name, }) .from(biddings) + .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`) .leftJoin(biddingDocuments, and( eq(biddingDocuments.biddingId, biddings.id), eq(biddingDocuments.documentType, 'evaluation_doc'), // 폐찰 문서 diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts index 73c2fe21..3254ae7e 100644 --- a/lib/bidding/validation.ts +++ b/lib/bidding/validation.ts @@ -99,7 +99,6 @@ export const createBiddingSchema = z.object({ submissionEndDate: z.string().optional(), - evaluationDate: z.string().optional(), // 회의 및 문서 hasSpecificationMeeting: z.boolean().default(false), @@ -220,7 +219,6 @@ export const createBiddingSchema = z.object({ submissionStartDate: z.string().optional(), submissionEndDate: z.string().optional(), - evaluationDate: z.string().optional(), hasSpecificationMeeting: z.boolean().optional(), hasPrDocument: z.boolean().optional(), diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx index d0ef97f1..8d6cb82d 100644 --- a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx +++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx @@ -37,7 +37,6 @@ interface PartnersSpecificationMeetingDialogProps { title: string preQuoteDate: string | null biddingRegistrationDate: string | null - evaluationDate: string | null hasSpecificationMeeting?: boolean // 사양설명회 여부 추가 } | null biddingCompanyId: number diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index bf76de62..087648ab 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -75,7 +75,6 @@ interface BiddingDetail { biddingRegistrationDate: Date | string | null submissionStartDate: Date | string | null submissionEndDate: Date | string | null - evaluationDate: Date | string | null currency: string budget: number | null targetPrice: number | null @@ -927,11 +926,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD })()}
)} - {biddingDetail.evaluationDate && ( -
- 평가일: {format(new Date(biddingDetail.evaluationDate), "yyyy-MM-dd HH:mm")} -
- )} +
diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx index 0f68ed68..f1cb0bdc 100644 --- a/lib/bidding/vendor/partners-bidding-list.tsx +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -181,7 +181,6 @@ export function PartnersBiddingList({ promises }: PartnersBiddingListProps) { title: rowAction.row.original.title, preQuoteDate: null, biddingRegistrationDate: rowAction.row.original.submissionStartDate?.toISOString() || null, - evaluationDate: null, hasSpecificationMeeting: rowAction.row.original.hasSpecificationMeeting || false, } : null} biddingCompanyId={rowAction?.row.original?.biddingCompanyId || 0} -- cgit v1.2.3 From d47334639bd717aa860563ec1020a29827524fd4 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 5 Dec 2025 06:29:23 +0000 Subject: (최겸)구매 결재일 기준 공고 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bidding/manage/bidding-schedule-editor.tsx | 340 ++++++--- lib/bidding/detail/service.ts | 97 ++- .../detail/table/bidding-detail-vendor-columns.tsx | 78 ++ .../detail/table/bidding-detail-vendor-table.tsx | 41 +- .../bidding-detail-vendor-toolbar-actions.tsx | 25 +- .../detail/table/price-adjustment-dialog.tsx | 195 +++++ .../table/vendor-price-adjustment-view-dialog.tsx | 324 +++++++++ lib/bidding/handlers.ts | 94 ++- lib/bidding/receive/biddings-receive-columns.tsx | 808 ++++++++++----------- lib/bidding/receive/biddings-receive-table.tsx | 593 +++++++-------- lib/bidding/service.ts | 61 +- .../vendor/components/pr-items-pricing-table.tsx | 125 +++- 12 files changed, 1922 insertions(+), 859 deletions(-) create mode 100644 lib/bidding/detail/table/price-adjustment-dialog.tsx create mode 100644 lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx (limited to 'components/bidding') diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx index 49659ae7..32ce6940 100644 --- a/components/bidding/manage/bidding-schedule-editor.tsx +++ b/components/bidding/manage/bidding-schedule-editor.tsx @@ -21,8 +21,10 @@ import { registerBidding } from '@/lib/bidding/detail/service' import { useToast } from '@/hooks/use-toast' import { format } from 'date-fns' interface BiddingSchedule { - submissionStartDate?: string - submissionEndDate?: string + submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일) + submissionStartTime?: string // 시작 시간 (HH:MM) + submissionDurationDays?: number // 기간 (시작일 + n일) + submissionEndTime?: string // 마감 시간 (HH:MM) remarks?: string isUrgent?: boolean hasSpecificationMeeting?: boolean @@ -149,6 +151,44 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc return new Date(kstTime).toISOString().slice(0, 16) } + // timestamp에서 시간(HH:MM) 추출 (KST 기준) + const extractTimeFromTimestamp = (date: string | Date | undefined | null): string => { + if (!date) return '' + const d = new Date(date) + // UTC 시간에 9시간을 더함 + const kstTime = d.getTime() + (9 * 60 * 60 * 1000) + const kstDate = new Date(kstTime) + const hours = kstDate.getUTCHours().toString().padStart(2, '0') + const minutes = kstDate.getUTCMinutes().toString().padStart(2, '0') + return `${hours}:${minutes}` + } + + // 예상 일정 계산 (오늘 기준 미리보기) + const getPreviewDates = () => { + const today = new Date() + today.setHours(0, 0, 0, 0) + + const startOffset = schedule.submissionStartOffset ?? 0 + const durationDays = schedule.submissionDurationDays ?? 7 + const startTime = schedule.submissionStartTime || '09:00' + const endTime = schedule.submissionEndTime || '18:00' + + // 시작일 계산 + const startDate = new Date(today) + startDate.setDate(startDate.getDate() + startOffset) + const [startHour, startMinute] = startTime.split(':').map(Number) + startDate.setHours(startHour, startMinute, 0, 0) + + // 마감일 계산 + const endDate = new Date(startDate) + endDate.setHours(0, 0, 0, 0) // 시작일의 날짜만 + endDate.setDate(endDate.getDate() + durationDays) + const [endHour, endMinute] = endTime.split(':').map(Number) + endDate.setHours(endHour, endMinute, 0, 0) + + return { startDate, endDate } + } + // 데이터 로딩 React.useEffect(() => { const loadSchedule = async () => { @@ -165,36 +205,36 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc }) setSchedule({ - submissionStartDate: toKstInputValue(bidding.submissionStartDate), - submissionEndDate: toKstInputValue(bidding.submissionEndDate), + submissionStartOffset: bidding.submissionStartOffset ?? 1, + submissionStartTime: extractTimeFromTimestamp(bidding.submissionStartDate) || '09:00', + submissionDurationDays: bidding.submissionDurationDays ?? 7, + submissionEndTime: extractTimeFromTimestamp(bidding.submissionEndDate) || '18:00', remarks: bidding.remarks || '', isUrgent: bidding.isUrgent || false, hasSpecificationMeeting: bidding.hasSpecificationMeeting || false, }) - // 사양설명회 정보 로드 - if (bidding.hasSpecificationMeeting) { - try { - const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId) - if (meetingDetails.success && meetingDetails.data) { - const meeting = meetingDetails.data - setSpecMeetingInfo({ - meetingDate: toKstInputValue(meeting.meetingDate), - meetingTime: meeting.meetingTime || '', - location: meeting.location || '', - address: meeting.address || '', - contactPerson: meeting.contactPerson || '', - contactPhone: meeting.contactPhone || '', - contactEmail: meeting.contactEmail || '', - agenda: meeting.agenda || '', - materials: meeting.materials || '', - notes: meeting.notes || '', - isRequired: meeting.isRequired || false, - }) - } - } catch (error) { - console.error('Failed to load specification meeting details:', error) + // 사양설명회 정보 로드 (T/F 무관하게 기존 데이터가 있으면 로드) + try { + const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId) + if (meetingDetails.success && meetingDetails.data) { + const meeting = meetingDetails.data + setSpecMeetingInfo({ + meetingDate: toKstInputValue(meeting.meetingDate), + meetingTime: meeting.meetingTime || '', + location: meeting.location || '', + address: meeting.address || '', + contactPerson: meeting.contactPerson || '', + contactPhone: meeting.contactPhone || '', + contactEmail: meeting.contactEmail || '', + agenda: meeting.agenda || '', + materials: meeting.materials || '', + notes: meeting.notes || '', + isRequired: meeting.isRequired || false, + }) } + } catch (error) { + console.error('Failed to load specification meeting details:', error) } } } catch (error) { @@ -258,10 +298,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc const handleBiddingInvitationClick = async () => { try { // 1. 입찰서 제출기간 검증 - if (!schedule.submissionStartDate || !schedule.submissionEndDate) { + if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) { toast({ title: '입찰서 제출기간 미설정', - description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.', + description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.', + variant: 'destructive', + }) + return + } + if (!schedule.submissionStartTime || !schedule.submissionEndTime) { + toast({ + title: '입찰서 제출시간 미설정', + description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.', variant: 'destructive', }) return @@ -484,10 +532,48 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc const userId = session?.user?.id?.toString() || '1' // 입찰서 제출기간 필수 검증 - if (!schedule.submissionStartDate || !schedule.submissionEndDate) { + if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) { toast({ title: '입찰서 제출기간 미설정', - description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.', + description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + if (!schedule.submissionStartTime || !schedule.submissionEndTime) { + toast({ + title: '입찰서 제출시간 미설정', + description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + // 오프셋/기간 검증 + if (schedule.submissionStartOffset < 0) { + toast({ + title: '시작일 오프셋 오류', + description: '시작일 오프셋은 0 이상이어야 합니다.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + if (schedule.submissionDurationDays < 1) { + toast({ + title: '기간 오류', + description: '입찰 기간은 최소 1일 이상이어야 합니다.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + // 긴급 입찰이 아닌 경우 당일 시작 불가 (오프셋 0) + if (!schedule.isUrgent && schedule.submissionStartOffset === 0) { + toast({ + title: '시작일 오류', + description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.', variant: 'destructive', }) setIsSubmitting(false) @@ -538,62 +624,55 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc } } - const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => { - // 마감일시 검증 - 현재일 이전 설정 불가 - if (field === 'submissionEndDate' && typeof value === 'string' && value) { - const selectedDate = new Date(value) - const now = new Date() - now.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정하여 날짜만 비교 - - if (selectedDate < now) { + const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean | number) => { + // 시작일 오프셋 검증 + if (field === 'submissionStartOffset' && typeof value === 'number') { + if (value < 0) { + toast({ + title: '시작일 오프셋 오류', + description: '시작일 오프셋은 0 이상이어야 합니다.', + variant: 'destructive', + }) + return + } + // 긴급 입찰이 아닌 경우 당일 시작(오프셋 0) 불가 + if (!schedule.isUrgent && value === 0) { toast({ - title: '마감일시 오류', - description: '마감일시는 현재일 이전으로 설정할 수 없습니다.', + title: '시작일 오프셋 오류', + description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.', variant: 'destructive', }) - return // 변경을 적용하지 않음 + return } } - // 긴급여부 미선택 시 당일 제출시작 불가 - if (field === 'submissionStartDate' && typeof value === 'string' && value) { - const selectedDate = new Date(value) - const today = new Date() - today.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정 - selectedDate.setHours(0, 0, 0, 0) - - // 현재 긴급 여부 확인 (field가 'isUrgent'인 경우 value 사용, 아니면 기존 schedule 값) - const isUrgent = field === 'isUrgent' ? (value as boolean) : schedule.isUrgent || false + // 기간 검증 + if (field === 'submissionDurationDays' && typeof value === 'number') { + if (value < 1) { + toast({ + title: '기간 오류', + description: '입찰 기간은 최소 1일 이상이어야 합니다.', + variant: 'destructive', + }) + return + } + } - // 긴급이 아닌 경우 당일 시작 불가 - if (!isUrgent && selectedDate.getTime() === today.getTime()) { + // 시간 형식 검증 (HH:MM) + if ((field === 'submissionStartTime' || field === 'submissionEndTime') && typeof value === 'string') { + const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/ + if (value && !timeRegex.test(value)) { toast({ - title: '제출 시작일시 오류', - description: '긴급 입찰이 아닌 경우 당일 제출 시작은 불가능합니다.', + title: '시간 형식 오류', + description: '시간은 HH:MM 형식으로 입력해주세요.', variant: 'destructive', }) - return // 변경을 적용하지 않음 + return } } setSchedule(prev => ({ ...prev, [field]: value })) - - // 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화 - if (field === 'hasSpecificationMeeting' && value === false) { - setSpecMeetingInfo({ - meetingDate: '', - meetingTime: '', - location: '', - address: '', - contactPerson: '', - contactPhone: '', - contactEmail: '', - agenda: '', - materials: '', - notes: '', - isRequired: false, - }) - } + // 사양설명회 실시 여부가 false로 변경되어도 상세 정보 초기화하지 않음 (기존 데이터 유지) } if (isLoading) { @@ -624,40 +703,98 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc 입찰서 제출 기간 +

+ 입찰공고(결재 완료) 시점을 기준으로 일정이 자동 계산됩니다. +

+ + {/* 시작일 설정 */}
- + +
+ handleScheduleChange('submissionStartOffset', parseInt(e.target.value) || 0)} + className={schedule.submissionStartOffset === undefined ? 'border-red-200' : ''} + disabled={readonly} + placeholder="0" + /> + 일 후 +
+ {schedule.submissionStartOffset === undefined && ( +

시작일 오프셋은 필수입니다

+ )} + {!schedule.isUrgent && schedule.submissionStartOffset === 0 && ( +

긴급 입찰만 당일 시작(0일) 가능

+ )} +
+
+ handleScheduleChange('submissionStartDate', e.target.value)} - className={!schedule.submissionStartDate ? 'border-red-200' : ''} + id="submission-start-time" + type="time" + value={schedule.submissionStartTime || ''} + onChange={(e) => handleScheduleChange('submissionStartTime', e.target.value)} + className={!schedule.submissionStartTime ? 'border-red-200' : ''} disabled={readonly} - min="1900-01-01T00:00" - max="2100-12-31T23:59" /> - {!schedule.submissionStartDate && ( -

제출 시작일시는 필수입니다

+ {!schedule.submissionStartTime && ( +

시작 시간은 필수입니다

+ )} +
+
+ + {/* 마감일 설정 */} +
+
+ +
+ handleScheduleChange('submissionDurationDays', parseInt(e.target.value) || 1)} + className={schedule.submissionDurationDays === undefined ? 'border-red-200' : ''} + disabled={readonly} + placeholder="7" + /> + 일간 +
+ {schedule.submissionDurationDays === undefined && ( +

입찰 기간은 필수입니다

)}
- + handleScheduleChange('submissionEndDate', e.target.value)} - className={!schedule.submissionEndDate ? 'border-red-200' : ''} + id="submission-end-time" + type="time" + value={schedule.submissionEndTime || ''} + onChange={(e) => handleScheduleChange('submissionEndTime', e.target.value)} + className={!schedule.submissionEndTime ? 'border-red-200' : ''} disabled={readonly} - min="1900-01-01T00:00" - max="2100-12-31T23:59" /> - {!schedule.submissionEndDate && ( -

제출 마감일시는 필수입니다

+ {!schedule.submissionEndTime && ( +

마감 시간은 필수입니다

)}
+ + {/* 예상 일정 미리보기 */} + {schedule.submissionStartOffset !== undefined && schedule.submissionDurationDays !== undefined && ( +
+

📅 예상 일정 (오늘 공고 기준)

+
+ 시작: {format(getPreviewDates().startDate, "yyyy-MM-dd HH:mm")} + ~ + 마감: {format(getPreviewDates().endDate, "yyyy-MM-dd HH:mm")} +
+
+ )} {/* 긴급 여부 */} @@ -690,8 +827,8 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc /> - {/* 사양설명회 상세 정보 */} - {schedule.hasSpecificationMeeting && ( + {/* 사양설명회 상세 정보 - T/F와 무관하게 기존 데이터가 있거나 hasSpecificationMeeting이 true면 표시 */} + {(schedule.hasSpecificationMeeting) && (
@@ -834,10 +971,19 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
- 입찰서 제출 기간: + 시작일: + + {schedule.submissionStartOffset !== undefined + ? `결재 후 ${schedule.submissionStartOffset}일, ${schedule.submissionStartTime || '미설정'}` + : '미설정' + } + +
+
+ 마감일: - {schedule.submissionStartDate && schedule.submissionEndDate - ? `${format(new Date(schedule.submissionStartDate), "yyyy-MM-dd HH:mm")} ~ ${format(new Date(schedule.submissionEndDate), "yyyy-MM-dd HH:mm")}` + {schedule.submissionDurationDays !== undefined + ? `시작일 + ${schedule.submissionDurationDays}일, ${schedule.submissionEndTime || '미설정'}` : '미설정' } diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 17ea8f28..4ef48d33 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -64,6 +64,12 @@ export async function getBiddingDetailData(biddingId: number): Promise { debugLog('registerBidding: Transaction started') - // 1. 입찰 상태를 오픈으로 변경 + + // 0. 입찰서 제출기간 계산 (오프셋 기반) + const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = bidding + + let calculatedStartDate = bidding.submissionStartDate + let calculatedEndDate = bidding.submissionEndDate + + // 오프셋 값이 있으면 날짜 계산 + if (submissionStartOffset !== null && submissionDurationDays !== null) { + // 시간 추출 (기본값: 시작 09:00, 마감 18:00) + const startTime = submissionStartDate + ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() } + : { hours: 9, minutes: 0 } + const endTime = submissionEndDate + ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() } + : { hours: 18, minutes: 0 } + + // baseDate = 현재일 날짜만 (00:00:00) + const baseDate = new Date() + baseDate.setHours(0, 0, 0, 0) + + // 시작일 = baseDate + offset일 + 시작시간 + calculatedStartDate = new Date(baseDate) + calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset) + calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0) + + // 마감일 = 시작일(날짜만) + duration일 + 마감시간 + calculatedEndDate = new Date(calculatedStartDate) + calculatedEndDate.setHours(0, 0, 0, 0) + calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays) + calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0) + + debugLog('registerBidding: Submission dates calculated', { + baseDate: baseDate.toISOString(), + calculatedStartDate: calculatedStartDate.toISOString(), + calculatedEndDate: calculatedEndDate.toISOString(), + }) + } + + // 1. 입찰 상태를 오픈으로 변경 + 제출기간 업데이트 await tx .update(biddings) .set({ status: 'bidding_opened', + submissionStartDate: calculatedStartDate, + submissionEndDate: calculatedEndDate, updatedBy: userName, updatedAt: new Date() }) @@ -2617,3 +2680,35 @@ export async function setSpecificationMeetingParticipation(biddingCompanyId: num return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' } } } + +// 연동제 정보 업데이트 +export async function updatePriceAdjustmentInfo(params: { + biddingCompanyId: number + shiPriceAdjustmentApplied: boolean | null + priceAdjustmentNote: string | null + hasChemicalSubstance: boolean | null +}): Promise<{ success: boolean; error?: string }> { + try { + const result = await db.update(biddingCompanies) + .set({ + shiPriceAdjustmentApplied: params.shiPriceAdjustmentApplied, + priceAdjustmentNote: params.priceAdjustmentNote, + hasChemicalSubstance: params.hasChemicalSubstance, + updatedAt: new Date(), + }) + .where(eq(biddingCompanies.id, params.biddingCompanyId)) + .returning({ biddingId: biddingCompanies.biddingId }) + + if (result.length > 0) { + const biddingId = result[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidatePath(`/evcp/bid/${biddingId}`) + } + + return { success: true } + } catch (error) { + console.error('Failed to update price adjustment info:', error) + return { success: false, error: '연동제 정보 업데이트에 실패했습니다.' } + } +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 5368b287..05c1a93d 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -31,6 +31,7 @@ interface GetVendorColumnsProps { } export function getBiddingDetailVendorColumns({ + onViewPriceAdjustment, onViewItemDetails, onSendBidding, onUpdateParticipation, @@ -238,6 +239,83 @@ export function getBiddingDetailVendorColumns({
), }, + { + accessorKey: 'priceAdjustmentResponse', + header: '연동제 응답', + cell: ({ row }) => { + const vendor = row.original + const response = vendor.priceAdjustmentResponse + + // 버튼 형태로 표시, 클릭 시 상세 다이얼로그 열기 + const getBadgeVariant = () => { + if (response === null || response === undefined) return 'outline' + return response ? 'default' : 'secondary' + } + + const getBadgeClass = () => { + if (response === true) return 'bg-green-600 hover:bg-green-700 cursor-pointer' + if (response === false) return 'hover:bg-gray-300 cursor-pointer' + return '' + } + + const getLabel = () => { + if (response === null || response === undefined) return '해당없음' + return response ? '예' : '아니오' + } + + return ( + onViewPriceAdjustment?.(vendor)} + > + {getLabel()} + + ) + }, + }, + { + accessorKey: 'shiPriceAdjustmentApplied', + header: 'SHI연동제적용', + cell: ({ row }) => { + const applied = row.original.shiPriceAdjustmentApplied + if (applied === null || applied === undefined) { + return 미정 + } + return ( + + {applied ? '적용' : '미적용'} + + ) + }, + }, + { + accessorKey: 'priceAdjustmentNote', + header: '연동제 Note', + cell: ({ row }) => { + const note = row.original.priceAdjustmentNote + return ( +
+ {note || '-'} +
+ ) + }, + }, + { + accessorKey: 'hasChemicalSubstance', + header: '화학물질', + cell: ({ row }) => { + const hasChemical = row.original.hasChemicalSubstance + if (hasChemical === null || hasChemical === undefined) { + return 미정 + } + return ( + + {hasChemical ? '해당' : '해당없음'} + + ) + }, + }, { id: 'actions', header: '작업', diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index a6f64964..407cc51c 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -10,9 +10,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog' import { BiddingAwardDialog } from './bidding-award-dialog' import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns' -import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service' +import { QuotationVendor } from '@/lib/bidding/detail/service' import { Bidding } from '@/db/schema' -import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog' +import { VendorPriceAdjustmentViewDialog } from './vendor-price-adjustment-view-dialog' import { QuotationHistoryDialog } from './quotation-history-dialog' import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog' import { ApplicationReasonDialog } from '@/lib/rfq-last/vendor/application-reason-dialog' @@ -98,8 +98,7 @@ export function BiddingDetailVendorTableContent({ const [selectedVendor, setSelectedVendor] = React.useState(null) const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false) const [isAwardRatioDialogOpen, setIsAwardRatioDialogOpen] = React.useState(false) - const [priceAdjustmentData, setPriceAdjustmentData] = React.useState(null) - const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) + const [isVendorPriceAdjustmentDialogOpen, setIsVendorPriceAdjustmentDialogOpen] = React.useState(false) const [quotationHistoryData, setQuotationHistoryData] = React.useState(null) const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false) const [approvalPreviewData, setApprovalPreviewData] = React.useState<{ @@ -116,28 +115,9 @@ export function BiddingDetailVendorTableContent({ } | null>(null) const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false) - 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 handleViewPriceAdjustment = (vendor: QuotationVendor) => { + setSelectedVendor(vendor) + setIsVendorPriceAdjustmentDialogOpen(true) } const handleViewQuotationHistory = async (vendor: QuotationVendor) => { @@ -299,11 +279,12 @@ export function BiddingDetailVendorTableContent({ }} /> - ([]) const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false) const [isCancelAwardDialogOpen, setIsCancelAwardDialogOpen] = React.useState(false) + const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 React.useEffect(() => { @@ -196,6 +198,19 @@ export function BiddingDetailVendorToolbarActions({ )} + {/* 연동제 적용여부: single select 시에만 활성화 */} + {(bidding.status === 'evaluation_of_bidding') && ( + + )} + {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */} {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && ( <> @@ -331,6 +346,14 @@ export function BiddingDetailVendorToolbarActions({ + {/* 연동제 적용여부 다이얼로그 */} + + ) } diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx new file mode 100644 index 00000000..14bbd843 --- /dev/null +++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx @@ -0,0 +1,195 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Switch } from "@/components/ui/switch" +import { useToast } from "@/hooks/use-toast" +import { updatePriceAdjustmentInfo } from "@/lib/bidding/detail/service" +import { QuotationVendor } from "@/lib/bidding/detail/service" +import { Loader2 } from "lucide-react" + +interface PriceAdjustmentDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendor: QuotationVendor | null + onSuccess: () => void +} + +export function PriceAdjustmentDialog({ + open, + onOpenChange, + vendor, + onSuccess, +}: PriceAdjustmentDialogProps) { + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // 폼 상태 + const [shiPriceAdjustmentApplied, setSHIPriceAdjustmentApplied] = React.useState(null) + const [priceAdjustmentNote, setPriceAdjustmentNote] = React.useState("") + const [hasChemicalSubstance, setHasChemicalSubstance] = React.useState(null) + + // 다이얼로그가 열릴 때 벤더 정보로 폼 초기화 + React.useEffect(() => { + if (open && vendor) { + setSHIPriceAdjustmentApplied(vendor.shiPriceAdjustmentApplied ?? null) + setPriceAdjustmentNote(vendor.priceAdjustmentNote || "") + setHasChemicalSubstance(vendor.hasChemicalSubstance ?? null) + } + }, [open, vendor]) + + const handleSubmit = async () => { + if (!vendor) return + + setIsSubmitting(true) + try { + const result = await updatePriceAdjustmentInfo({ + biddingCompanyId: vendor.id, + shiPriceAdjustmentApplied, + priceAdjustmentNote: priceAdjustmentNote || null, + hasChemicalSubstance, + }) + + if (result.success) { + toast({ + title: "저장 완료", + description: "연동제 정보가 저장되었습니다.", + }) + onOpenChange(false) + onSuccess() + } else { + toast({ + title: "오류", + description: result.error || "저장 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } catch (error) { + console.error("연동제 정보 저장 오류:", error) + toast({ + title: "오류", + description: "저장 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsSubmitting(false) + } + } + + if (!vendor) return null + + return ( + + + + 연동제 적용 설정 + + {vendor.vendorName} 업체의 연동제 적용 여부 및 화학물질 정보를 설정합니다. + + + +
+ {/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */} +
+
+ +

+ 업체가 제출한 연동제 적용 요청 여부입니다. +

+
+ + {vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'} + +
+ + {/* SHI 연동제 적용여부 */} +
+
+ +

+ 해당 업체에 연동제를 적용할지 결정합니다. +

+
+
+ + 미적용 + + setSHIPriceAdjustmentApplied(checked)} + /> + + 적용 + +
+
+ + {/* 연동제 Note */} +
+ +