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 +- .../procurement-item-selector-dialog-single.tsx | 4 +- db/schema/bidding.ts | 2 +- lib/bidding/actions.ts | 8 + lib/bidding/approval-actions.ts | 2 + lib/bidding/detail/service.ts | 151 +++---- .../detail/table/bidding-detail-vendor-table.tsx | 5 +- .../bidding-detail-vendor-toolbar-actions.tsx | 195 +++------ lib/bidding/handlers.ts | 12 +- .../list/biddings-table-toolbar-actions.tsx | 54 ++- lib/bidding/list/export-biddings-to-excel.ts | 212 +++++++++ .../manage/export-bidding-items-to-excel.ts | 161 +++++++ .../manage/import-bidding-items-from-excel.ts | 271 ++++++++++++ lib/bidding/manage/project-utils.ts | 87 ++++ lib/bidding/selection/actions.ts | 69 +++ lib/bidding/selection/bidding-info-card.tsx | 2 +- lib/bidding/selection/bidding-item-table.tsx | 192 +++++++++ .../selection/bidding-selection-detail-content.tsx | 11 +- lib/bidding/selection/biddings-selection-table.tsx | 6 +- lib/bidding/selection/selection-result-form.tsx | 213 +++++++-- lib/bidding/selection/vendor-selection-table.tsx | 4 +- lib/bidding/service.ts | 133 +++++- .../vendor/components/pr-items-pricing-table.tsx | 18 +- .../vendor/export-partners-biddings-to-excel.ts | 278 ++++++++++++ .../vendor/partners-bidding-list-columns.tsx | 48 +-- .../vendor/partners-bidding-toolbar-actions.tsx | 34 +- .../detail/general-contract-basic-info.tsx | 478 +++++++++++++++------ .../detail/general-contract-items-table.tsx | 43 +- lib/general-contracts/service.ts | 11 +- lib/procurement-items/service.ts | 15 +- lib/soap/ecc/mapper/bidding-and-pr-mapper.ts | 15 + 33 files changed, 2728 insertions(+), 487 deletions(-) create mode 100644 lib/bidding/list/export-biddings-to-excel.ts create mode 100644 lib/bidding/manage/export-bidding-items-to-excel.ts create mode 100644 lib/bidding/manage/import-bidding-items-from-excel.ts create mode 100644 lib/bidding/manage/project-utils.ts create mode 100644 lib/bidding/selection/bidding-item-table.tsx create mode 100644 lib/bidding/vendor/export-partners-biddings-to-excel.ts 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) + } + }} + /> +
diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx index 84fd85ff..a1b98468 100644 --- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx +++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx @@ -23,6 +23,7 @@ export interface ProcurementItemSelectorDialogSingleProps { title?: string; description?: string; showConfirmButtons?: boolean; + disabled?: boolean; } /** @@ -78,6 +79,7 @@ export function ProcurementItemSelectorDialogSingle({ title = "1회성 품목 선택", description = "1회성 품목을 검색하고 선택해주세요.", showConfirmButtons = false, + disabled = false, }: ProcurementItemSelectorDialogSingleProps) { const [open, setOpen] = useState(false); const [tempSelectedProcurementItem, setTempSelectedProcurementItem] = @@ -128,7 +130,7 @@ export function ProcurementItemSelectorDialogSingle({ return ( - - )} - - {/* 발주비율 산정: single select 시에만 활성화 */} - {(bidding.status === 'evaluation_of_bidding') && ( - - )} - - {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */} - {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && ( + {/* 상태별 액션 버튼 - 읽기 전용이 아닐 때만 표시 */} + {!readOnly && ( <> - - + {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */} + {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && ( + + )} + + {/* 발주비율 산정: single select 시에만 활성화 */} + {(bidding.status === 'evaluation_of_bidding') && ( + + )} + + {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */} + {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && ( + <> + + + + )} + + {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */} + {winnerVendor && ( + + )} )} - {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */} - {winnerVendor && ( - - )} {/* 구분선 */} {(bidding.status === 'bidding_generated' || bidding.status === 'bidding_disposal') && ( diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts index 11955a39..c64d9527 100644 --- a/lib/bidding/handlers.ts +++ b/lib/bidding/handlers.ts @@ -127,6 +127,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { biddingNumber: string; projectName?: string; itemName?: string; + awardCount: string; biddingType: string; bidPicName?: string; supplyPicName?: string; @@ -181,7 +182,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const { bidding, biddingItems, vendors, message, specificationMeeting, requestedAt } = payload; // 제목 - const title = bidding.title || '입찰'; + const title = bidding.title || ''; // 입찰명 const biddingTitle = bidding.title || ''; @@ -190,7 +191,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const biddingNumber = bidding.biddingNumber || ''; // 낙찰업체수 - const winnerCount = '1'; // 기본값, 실제로는 bidding 설정에서 가져와야 함 + const awardCount = bidding.awardCount || ''; // 계약구분 const contractType = bidding.biddingType || ''; @@ -199,7 +200,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const prNumber = ''; // 예산 - const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const budget = bidding.budget ? bidding.budget.toLocaleString() : ''; // 내정가 const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; @@ -272,7 +273,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { 제목: title, 입찰명: biddingTitle, 입찰번호: biddingNumber, - 낙찰업체수: winnerCount, + 낙찰업체수: awardCount, 계약구분: contractType, 'P/R번호': prNumber, 예산: budget, @@ -637,6 +638,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: { biddingType: biddings.biddingType, bidPicName: biddings.bidPicName, supplyPicName: biddings.supplyPicName, + budget: biddings.budget, targetPrice: biddings.targetPrice, awardCount: biddings.awardCount, }) @@ -684,7 +686,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: { const biddingNumber = bidding.biddingNumber || ''; const winnerCount = (bidding.awardCount === 'single' ? 1 : bidding.awardCount === 'multiple' ? 2 : 1).toString(); const contractType = bidding.biddingType || ''; - const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const budget = bidding.budget ? bidding.budget.toLocaleString() : ''; const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; const biddingManager = bidding.bidPicName || bidding.supplyPicName || ''; const biddingOverview = bidding.itemName || ''; diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx index 33368218..b0007c8c 100644 --- a/lib/bidding/list/biddings-table-toolbar-actions.tsx +++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx @@ -7,7 +7,7 @@ import { } from "lucide-react" import { toast } from "sonner" import { useSession } from "next-auth/react" -import { exportTableToExcel } from "@/lib/export" +import { exportBiddingsToExcel } from "./export-biddings-to-excel" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -92,6 +92,23 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio return selectedBiddings.length === 1 && selectedBiddings[0].status === 'bidding_generated' }, [selectedBiddings]) + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + try { + setIsExporting(true) + await exportBiddingsToExcel(table, { + filename: "입찰목록", + onlySelected: false, + }) + toast.success("Excel 파일이 다운로드되었습니다.") + } catch (error) { + console.error("Excel export error:", error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + } finally { + setIsExporting(false) + } + }, [table]) + return ( <>
@@ -100,6 +117,17 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio // 성공 시 테이블 새로고침 등 추가 작업 // window.location.reload() }} /> + {/* Excel 내보내기 버튼 */} + {/* 전송하기 (업체선정 완료된 입찰만) */} {/* 삭제 버튼 */} - - - - - +
{/* 전송 다이얼로그 */} diff --git a/lib/bidding/list/export-biddings-to-excel.ts b/lib/bidding/list/export-biddings-to-excel.ts new file mode 100644 index 00000000..8b13e38d --- /dev/null +++ b/lib/bidding/list/export-biddings-to-excel.ts @@ -0,0 +1,212 @@ +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" +import { BiddingListItem } from "@/db/schema" +import { + biddingStatusLabels, + contractTypeLabels, + biddingTypeLabels, +} from "@/db/schema" +import { formatDate } from "@/lib/utils" + +// BiddingListItem 확장 타입 (manager 정보 포함) +type BiddingListItemWithManagerCode = BiddingListItem & { + bidPicName?: string | null + supplyPicName?: string | null +} + +/** + * 입찰 목록을 Excel로 내보내기 + * - 계약구분, 진행상태, 입찰유형은 라벨(명칭)로 변환 + * - 입찰서 제출기간은 submissionStartDate, submissionEndDate 기준 + * - 등록일시는 년, 월, 일 형식 + */ +export async function exportBiddingsToExcel( + table: Table, + { + filename = "입찰목록", + onlySelected = false, + }: { + filename?: string + onlySelected?: boolean + } = {} +): Promise { + // 테이블에서 실제 사용 중인 leaf columns 가져오기 + const allColumns = table.getAllLeafColumns() + + // select, actions 컬럼 제외 + const columns = allColumns.filter( + (col) => !["select", "actions"].includes(col.id) + ) + + // 헤더 행 생성 (excelHeader 사용) + const headerRow = columns.map((col) => { + const excelHeader = (col.columnDef.meta as any)?.excelHeader + return typeof excelHeader === "string" ? excelHeader : col.id + }) + + // 데이터 행 생성 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => { + const original = row.original + return columns.map((col) => { + const colId = col.id + let value: any + + // 특별 처리 필요한 컬럼들 + switch (colId) { + case "contractType": + // 계약구분: 라벨로 변환 + value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType + break + + case "status": + // 진행상태: 라벨로 변환 + value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status + break + + case "biddingType": + // 입찰유형: 라벨로 변환 + value = biddingTypeLabels[original.biddingType as keyof typeof biddingTypeLabels] || original.biddingType + break + + case "submissionPeriod": + // 입찰서 제출기간: submissionStartDate, submissionEndDate 기준 + const startDate = original.submissionStartDate + const endDate = original.submissionEndDate + + if (!startDate || !endDate) { + value = "-" + } else { + const startObj = new Date(startDate) + const endObj = new Date(endDate) + + // KST 변환 (UTC+9) + const formatKst = (d: Date) => { + const kstDate = new Date(d.getTime() + 9 * 60 * 60 * 1000) + return kstDate.toISOString().slice(0, 16).replace('T', ' ') + } + + value = `${formatKst(startObj)} ~ ${formatKst(endObj)}` + } + break + + case "updatedAt": + // 등록일시: 년, 월, 일 형식만 + if (original.updatedAt) { + value = formatDate(original.updatedAt, "KR") + } else { + value = "-" + } + break + + case "biddingRegistrationDate": + // 입찰등록일: 년, 월, 일 형식만 + if (original.biddingRegistrationDate) { + value = formatDate(original.biddingRegistrationDate, "KR") + } else { + value = "-" + } + break + + case "projectName": + // 프로젝트: 코드와 이름 조합 + const code = original.projectCode + const name = original.projectName + value = code && name ? `${code} (${name})` : (code || name || "-") + break + + case "hasSpecificationMeeting": + // 사양설명회: Yes/No + value = original.hasSpecificationMeeting ? "Yes" : "No" + break + + default: + // 기본값: row.getValue 사용 + value = row.getValue(colId) + + // null/undefined 처리 + if (value == null) { + value = "" + } + + // 객체인 경우 JSON 문자열로 변환 + if (typeof value === "object") { + value = JSON.stringify(value) + } + break + } + + return value + }) + }) + + // 최종 sheetData + const sheetData = [headerRow, ...dataRows] + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, columns.length, filename) +} + +/** + * Excel 파일 생성 및 다운로드 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string +): Promise { + // ExcelJS 워크북/시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 (첫 번째 행) + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + }) + + // 칼럼 너비 자동 조정 + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // 최종 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + diff --git a/lib/bidding/manage/export-bidding-items-to-excel.ts b/lib/bidding/manage/export-bidding-items-to-excel.ts new file mode 100644 index 00000000..814648a7 --- /dev/null +++ b/lib/bidding/manage/export-bidding-items-to-excel.ts @@ -0,0 +1,161 @@ +import ExcelJS from "exceljs" +import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor" +import { getProjectCodesByIds } from "./project-utils" + +/** + * 입찰품목 목록을 Excel로 내보내기 + */ +export async function exportBiddingItemsToExcel( + items: PRItemInfo[], + { + filename = "입찰품목목록", + }: { + filename?: string + } = {} +): Promise { + // 프로젝트 ID 목록 수집 + const projectIds = items + .map((item) => item.projectId) + .filter((id): id is number => id != null && id > 0) + + // 프로젝트 코드 맵 조회 + const projectCodeMap = await getProjectCodesByIds(projectIds) + + // 헤더 정의 + const headers = [ + "프로젝트코드", + "프로젝트명", + "자재그룹코드", + "자재그룹명", + "자재코드", + "자재명", + "수량", + "수량단위", + "중량", + "중량단위", + "납품요청일", + "가격단위", + "구매단위", + "자재순중량", + "내정단가", + "내정금액", + "내정통화", + "예산금액", + "예산통화", + "실적금액", + "실적통화", + "WBS코드", + "WBS명", + "코스트센터코드", + "코스트센터명", + "GL계정코드", + "GL계정명", + "PR번호", + ] + + // 데이터 행 생성 + const dataRows = items.map((item) => { + // 프로젝트 코드 조회 + const projectCode = item.projectId + ? projectCodeMap.get(item.projectId) || "" + : "" + + return [ + projectCode, + item.projectInfo || "", + item.materialGroupNumber || "", + item.materialGroupInfo || "", + item.materialNumber || "", + item.materialInfo || "", + item.quantity || "", + item.quantityUnit || "", + item.totalWeight || "", + item.weightUnit || "", + item.requestedDeliveryDate || "", + item.priceUnit || "", + item.purchaseUnit || "", + item.materialWeight || "", + item.targetUnitPrice || "", + item.targetAmount || "", + item.targetCurrency || "KRW", + item.budgetAmount || "", + item.budgetCurrency || "KRW", + item.actualAmount || "", + item.actualCurrency || "KRW", + item.wbsCode || "", + item.wbsName || "", + item.costCenterCode || "", + item.costCenterName || "", + item.glAccountCode || "", + item.glAccountName || "", + item.prNumber || "", + ] + }) + + // 최종 sheetData + const sheetData = [headers, ...dataRows] + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, headers.length, filename) +} + +/** + * Excel 파일 생성 및 다운로드 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string +): Promise { + // ExcelJS 워크북/시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 (첫 번째 행) + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + }) + + // 칼럼 너비 자동 조정 + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // 최종 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + diff --git a/lib/bidding/manage/import-bidding-items-from-excel.ts b/lib/bidding/manage/import-bidding-items-from-excel.ts new file mode 100644 index 00000000..2e0dfe33 --- /dev/null +++ b/lib/bidding/manage/import-bidding-items-from-excel.ts @@ -0,0 +1,271 @@ +import ExcelJS from "exceljs" +import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor" +import { getProjectIdByCodeAndName } from "./project-utils" + +export interface ImportBiddingItemsResult { + success: boolean + items: PRItemInfo[] + errors: string[] +} + +/** + * Excel 파일에서 입찰품목 데이터 파싱 + */ +export async function importBiddingItemsFromExcel( + file: File +): Promise { + const errors: string[] = [] + const items: PRItemInfo[] = [] + + try { + const workbook = new ExcelJS.Workbook() + const arrayBuffer = await file.arrayBuffer() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.worksheets[0] + if (!worksheet) { + return { + success: false, + items: [], + errors: ["Excel 파일에 시트가 없습니다."], + } + } + + // 헤더 행 읽기 (첫 번째 행) + const headerRow = worksheet.getRow(1) + const headerValues = headerRow.values as ExcelJS.CellValue[] + + // 헤더 매핑 생성 + const headerMap: Record = {} + const expectedHeaders = [ + "프로젝트코드", + "프로젝트명", + "자재그룹코드", + "자재그룹명", + "자재코드", + "자재명", + "수량", + "수량단위", + "중량", + "중량단위", + "납품요청일", + "가격단위", + "구매단위", + "자재순중량", + "내정단가", + "내정금액", + "내정통화", + "예산금액", + "예산통화", + "실적금액", + "실적통화", + "WBS코드", + "WBS명", + "코스트센터코드", + "코스트센터명", + "GL계정코드", + "GL계정명", + "PR번호", + ] + + // 헤더 인덱스 매핑 + for (let i = 1; i < headerValues.length; i++) { + const headerValue = String(headerValues[i] || "").trim() + if (headerValue && expectedHeaders.includes(headerValue)) { + headerMap[headerValue] = i + } + } + + // 필수 헤더 확인 + const requiredHeaders = ["자재그룹코드", "자재그룹명"] + const missingHeaders = requiredHeaders.filter( + (h) => !headerMap[h] + ) + if (missingHeaders.length > 0) { + errors.push( + `필수 컬럼이 없습니다: ${missingHeaders.join(", ")}` + ) + } + + // 데이터 행 읽기 (2번째 행부터) + for (let rowIndex = 2; rowIndex <= worksheet.rowCount; rowIndex++) { + const row = worksheet.getRow(rowIndex) + const rowValues = row.values as ExcelJS.CellValue[] + + // 빈 행 건너뛰기 + if (rowValues.every((val) => !val || String(val).trim() === "")) { + continue + } + + // 셀 값 추출 헬퍼 + const getCellValue = (headerName: string): string => { + const colIndex = headerMap[headerName] + if (!colIndex) return "" + const value = rowValues[colIndex] + if (value == null) return "" + + // ExcelJS 객체 처리 + if (typeof value === "object" && "text" in value) { + return String((value as any).text || "") + } + + // 날짜 처리 + if (value instanceof Date) { + return value.toISOString().split("T")[0] + } + + return String(value).trim() + } + + // 필수값 검증 + const materialGroupNumber = getCellValue("자재그룹코드") + const materialGroupInfo = getCellValue("자재그룹명") + + if (!materialGroupNumber || !materialGroupInfo) { + errors.push( + `${rowIndex}번 행: 자재그룹코드와 자재그룹명은 필수입니다.` + ) + continue + } + + // 수량 또는 중량 검증 + const quantity = getCellValue("수량") + const totalWeight = getCellValue("중량") + const quantityUnit = getCellValue("수량단위") + const weightUnit = getCellValue("중량단위") + + if (!quantity && !totalWeight) { + errors.push( + `${rowIndex}번 행: 수량 또는 중량 중 하나는 필수입니다.` + ) + continue + } + + if (quantity && !quantityUnit) { + errors.push( + `${rowIndex}번 행: 수량이 있으면 수량단위가 필수입니다.` + ) + continue + } + + if (totalWeight && !weightUnit) { + errors.push( + `${rowIndex}번 행: 중량이 있으면 중량단위가 필수입니다.` + ) + continue + } + + // 납품요청일 검증 + const requestedDeliveryDate = getCellValue("납품요청일") + if (!requestedDeliveryDate) { + errors.push( + `${rowIndex}번 행: 납품요청일은 필수입니다.` + ) + continue + } + + // 날짜 형식 검증 + const dateRegex = /^\d{4}-\d{2}-\d{2}$/ + if (requestedDeliveryDate && !dateRegex.test(requestedDeliveryDate)) { + errors.push( + `${rowIndex}번 행: 납품요청일 형식이 올바르지 않습니다. (YYYY-MM-DD 형식)` + ) + continue + } + + // 내정단가 검증 (필수) + const targetUnitPrice = getCellValue("내정단가") + if (!targetUnitPrice || parseFloat(targetUnitPrice.replace(/,/g, "")) <= 0) { + errors.push( + `${rowIndex}번 행: 내정단가는 필수이며 0보다 커야 합니다.` + ) + continue + } + + // 숫자 값 정리 (콤마 제거) + const cleanNumber = (value: string): string => { + return value.replace(/,/g, "").trim() + } + + // 프로젝트 ID 조회 (프로젝트코드와 프로젝트명으로) + const projectCode = getCellValue("프로젝트코드") + const projectName = getCellValue("프로젝트명") + let projectId: number | null = null + + if (projectCode && projectName) { + projectId = await getProjectIdByCodeAndName(projectCode, projectName) + if (!projectId) { + errors.push( + `${rowIndex}번 행: 프로젝트코드 "${projectCode}"와 프로젝트명 "${projectName}"에 해당하는 프로젝트를 찾을 수 없습니다.` + ) + // 프로젝트를 찾지 못해도 계속 진행 (경고만 표시) + } + } + + // PRItemInfo 객체 생성 + const item: PRItemInfo = { + id: -(rowIndex - 1), // 임시 ID (음수) + prNumber: getCellValue("PR번호") || null, + projectId: projectId, + projectInfo: projectName || null, + shi: null, + quantity: quantity ? cleanNumber(quantity) : null, + quantityUnit: quantityUnit || null, + totalWeight: totalWeight ? cleanNumber(totalWeight) : null, + weightUnit: weightUnit || null, + materialDescription: null, + hasSpecDocument: false, + requestedDeliveryDate: requestedDeliveryDate || null, + isRepresentative: false, + annualUnitPrice: null, + currency: "KRW", + materialGroupNumber: materialGroupNumber || null, + materialGroupInfo: materialGroupInfo || null, + materialNumber: getCellValue("자재코드") || null, + materialInfo: getCellValue("자재명") || null, + priceUnit: getCellValue("가격단위") || "1", + purchaseUnit: getCellValue("구매단위") || "EA", + materialWeight: getCellValue("자재순중량") || null, + wbsCode: getCellValue("WBS코드") || null, + wbsName: getCellValue("WBS명") || null, + costCenterCode: getCellValue("코스트센터코드") || null, + costCenterName: getCellValue("코스트센터명") || null, + glAccountCode: getCellValue("GL계정코드") || null, + glAccountName: getCellValue("GL계정명") || null, + targetUnitPrice: cleanNumber(targetUnitPrice) || null, + targetAmount: getCellValue("내정금액") + ? cleanNumber(getCellValue("내정금액")) + : null, + targetCurrency: getCellValue("내정통화") || "KRW", + budgetAmount: getCellValue("예산금액") + ? cleanNumber(getCellValue("예산금액")) + : null, + budgetCurrency: getCellValue("예산통화") || "KRW", + actualAmount: getCellValue("실적금액") + ? cleanNumber(getCellValue("실적금액")) + : null, + actualCurrency: getCellValue("실적통화") || "KRW", + } + + items.push(item) + } + + return { + success: errors.length === 0, + items, + errors, + } + } catch (error) { + console.error("Excel import error:", error) + return { + success: false, + items: [], + errors: [ + error instanceof Error + ? error.message + : "Excel 파일 파싱 중 오류가 발생했습니다.", + ], + } + } +} + diff --git a/lib/bidding/manage/project-utils.ts b/lib/bidding/manage/project-utils.ts new file mode 100644 index 00000000..92744695 --- /dev/null +++ b/lib/bidding/manage/project-utils.ts @@ -0,0 +1,87 @@ +'use server' + +import db from '@/db/db' +import { projects } from '@/db/schema' +import { eq, and, inArray } from 'drizzle-orm' + +/** + * 프로젝트 ID로 프로젝트 코드 조회 + */ +export async function getProjectCodeById(projectId: number): Promise { + try { + const result = await db + .select({ code: projects.code }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1) + + return result[0]?.code || null + } catch (error) { + console.error('Failed to get project code by id:', error) + return null + } +} + +/** + * 프로젝트 코드와 이름으로 프로젝트 ID 조회 + */ +export async function getProjectIdByCodeAndName( + projectCode: string, + projectName: string +): Promise { + try { + if (!projectCode || !projectName) { + return null + } + + const result = await db + .select({ id: projects.id }) + .from(projects) + .where( + and( + eq(projects.code, projectCode.trim()), + eq(projects.name, projectName.trim()) + ) + ) + .limit(1) + + return result[0]?.id || null + } catch (error) { + console.error('Failed to get project id by code and name:', error) + return null + } +} + +/** + * 여러 프로젝트 ID로 프로젝트 코드 맵 조회 (성능 최적화) + */ +export async function getProjectCodesByIds( + projectIds: number[] +): Promise> { + try { + if (projectIds.length === 0) { + return new Map() + } + + const uniqueIds = [...new Set(projectIds.filter(id => id != null))] + if (uniqueIds.length === 0) { + return new Map() + } + + const result = await db + .select({ id: projects.id, code: projects.code }) + .from(projects) + .where(inArray(projects.id, uniqueIds)) + + const map = new Map() + result.forEach((project) => { + map.set(project.id, project.code) + }) + + return map + } catch (error) { + console.error('Failed to get project codes by ids:', error) + return new Map() + } +} + diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts index f19fbe6d..91550960 100644 --- a/lib/bidding/selection/actions.ts +++ b/lib/bidding/selection/actions.ts @@ -131,6 +131,75 @@ export async function saveSelectionResult(data: SaveSelectionResultData) { } } +// 선정결과 조회 +export async function getSelectionResult(biddingId: number) { + try { + // 선정결과 조회 (selectedCompanyId가 null인 레코드) + const allResults = await db + .select() + .from(vendorSelectionResults) + .where(eq(vendorSelectionResults.biddingId, biddingId)) + + // @ts-ignore + const existingResult = allResults.filter((result: any) => result.selectedCompanyId === null).slice(0, 1) + + if (existingResult.length === 0) { + return { + success: true, + data: { + summary: '', + attachments: [] + } + } + } + + const result = existingResult[0] + + // 첨부파일 조회 + const documents = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + mimeType: biddingDocuments.mimeType, + filePath: biddingDocuments.filePath, + uploadedAt: biddingDocuments.uploadedAt + }) + .from(biddingDocuments) + .where(and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'selection_result') + )) + + return { + success: true, + data: { + summary: result.evaluationSummary || '', + attachments: documents.map(doc => ({ + id: doc.id, + fileName: doc.fileName || doc.originalFileName || '', + originalFileName: doc.originalFileName || '', + fileSize: doc.fileSize || 0, + mimeType: doc.mimeType || '', + filePath: doc.filePath || '', + uploadedAt: doc.uploadedAt + })) + } + } + } catch (error) { + console.error('Failed to get selection result:', error) + return { + success: false, + error: '선정결과 조회 중 오류가 발생했습니다.', + data: { + summary: '', + attachments: [] + } + } + } +} + // 견적 히스토리 조회 export async function getQuotationHistory(biddingId: number, vendorId: number) { try { diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx index 8864e7db..b363f538 100644 --- a/lib/bidding/selection/bidding-info-card.tsx +++ b/lib/bidding/selection/bidding-info-card.tsx @@ -56,7 +56,7 @@ export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) { 입찰유형
- {bidding.isPublic ? '공개입찰' : '비공개입찰'} + {bidding.biddingType}
diff --git a/lib/bidding/selection/bidding-item-table.tsx b/lib/bidding/selection/bidding-item-table.tsx new file mode 100644 index 00000000..c101f7e7 --- /dev/null +++ b/lib/bidding/selection/bidding-item-table.tsx @@ -0,0 +1,192 @@ +'use client' + +import * as React from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + getPRItemsForBidding, + getVendorPricesForBidding +} from '@/lib/bidding/detail/service' +import { formatNumber } from '@/lib/utils' +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' + +interface BiddingItemTableProps { + biddingId: number +} + +export function BiddingItemTable({ biddingId }: BiddingItemTableProps) { + const [data, setData] = React.useState<{ + prItems: any[] + vendorPrices: any[] + }>({ prItems: [], vendorPrices: [] }) + const [loading, setLoading] = React.useState(true) + + React.useEffect(() => { + const loadData = async () => { + try { + setLoading(true) + const [prItems, vendorPrices] = await Promise.all([ + getPRItemsForBidding(biddingId), + getVendorPricesForBidding(biddingId) + ]) + console.log('prItems', prItems) + console.log('vendorPrices', vendorPrices) + setData({ prItems, vendorPrices }) + } catch (error) { + console.error('Failed to load bidding items:', error) + } finally { + setLoading(false) + } + } + + loadData() + }, [biddingId]) + + if (loading) { + return ( + + + 응찰품목 + + +
+
로딩 중...
+
+
+
+ ) + } + + const { prItems, vendorPrices } = data + + // Calculate Totals + const totalQuantity = prItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0) + const totalWeight = prItems.reduce((sum, item) => sum + Number(item.totalWeight || 0), 0) + const totalTargetAmount = prItems.reduce((sum, item) => sum + Number(item.targetAmount || 0), 0) + + // Calculate Vendor Totals + const vendorTotals = vendorPrices.map(vendor => { + const total = vendor.itemPrices.reduce((sum: number, item: any) => sum + Number(item.amount || 0), 0) + return { + companyId: vendor.companyId, + totalAmount: total + } + }) + + return ( + + + 응찰품목 + + + +
+ + + {/* Header Row 1: Base Info + Vendor Groups */} + + + + + + + + + + + + + + {vendorPrices.map((vendor) => ( + + ))} + + {/* Header Row 2: Vendor Sub-columns */} + + {vendorPrices.map((vendor) => ( + + + + + + + ))} + + + + {/* Summary Row */} + + + + + + + + + + + {vendorPrices.map((vendor) => { + const vTotal = vendorTotals.find(t => t.companyId === vendor.companyId)?.totalAmount || 0 + const ratio = totalTargetAmount > 0 ? (vTotal / totalTargetAmount) * 100 : 0 + return ( + + + + + + + ) + })} + + + {/* Data Rows */} + {prItems.map((item) => ( + + + + + + + + + + + + + + {vendorPrices.map((vendor) => { + const bidItem = vendor.itemPrices.find((p: any) => p.prItemId === item.id) + const bidAmount = bidItem ? bidItem.amount : 0 + const targetAmt = Number(item.targetAmount || 0) + const ratio = targetAmt > 0 && bidAmount > 0 ? (bidAmount / targetAmt) * 100 : 0 + + return ( + + + + + + + ) + })} + + ))} + +
자재번호자재내역자재내역상세구매단위수량단위총중량중량단위내정단가내정액통화 + {vendor.companyName} +
단가총액통화내정액(%)
합계{formatNumber(totalQuantity)}-{formatNumber(totalWeight)}--{formatNumber(totalTargetAmount)}KRW-{formatNumber(vTotal)}{vendor.currency}{formatNumber(ratio, 0)}%
{item.materialNumber}{item.materialInfo}{item.specification}{item.purchaseUnit}{formatNumber(item.quantity)}{item.quantityUnit}{formatNumber(item.totalWeight)}{item.weightUnit}{formatNumber(item.targetUnitPrice)}{formatNumber(item.targetAmount)}{item.currency} + {bidItem ? formatNumber(bidItem.unitPrice) : '-'} + + {bidItem ? formatNumber(bidItem.amount) : '-'} + + {vendor.currency} + + {bidItem && ratio > 0 ? `${formatNumber(ratio, 0)}%` : '-'} +
+
+ +
+
+
+ ) +} + diff --git a/lib/bidding/selection/bidding-selection-detail-content.tsx b/lib/bidding/selection/bidding-selection-detail-content.tsx index 45d5d402..887498dc 100644 --- a/lib/bidding/selection/bidding-selection-detail-content.tsx +++ b/lib/bidding/selection/bidding-selection-detail-content.tsx @@ -5,6 +5,7 @@ import { Bidding } from '@/db/schema' import { BiddingInfoCard } from './bidding-info-card' import { SelectionResultForm } from './selection-result-form' import { VendorSelectionTable } from './vendor-selection-table' +import { BiddingItemTable } from './bidding-item-table' interface BiddingSelectionDetailContentProps { biddingId: number @@ -17,6 +18,9 @@ export function BiddingSelectionDetailContent({ }: BiddingSelectionDetailContentProps) { const [refreshKey, setRefreshKey] = React.useState(0) + // 입찰평가중 상태가 아니면 읽기 전용 + const isReadOnly = bidding.status !== 'evaluation_of_bidding' + const handleRefresh = React.useCallback(() => { setRefreshKey(prev => prev + 1) }, []) @@ -27,7 +31,7 @@ export function BiddingSelectionDetailContent({ {/* 선정결과 폼 */} - + {/* 업체선정 테이블 */} + + {/* 응찰품목 테이블 */} + + ) } diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx index c3990e7b..41225531 100644 --- a/lib/bidding/selection/biddings-selection-table.tsx +++ b/lib/bidding/selection/biddings-selection-table.tsx @@ -84,13 +84,13 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps switch (rowAction.type) { case "view": // 상세 페이지로 이동 - // 입찰평가중일때만 상세보기 가능 - if (rowAction.row.original.status === 'evaluation_of_bidding') { + // 입찰평가중, 업체선정, 차수증가, 재입찰 상태일 때 상세보기 가능 + if (['evaluation_of_bidding', 'vendor_selected', 'round_increase', 'rebidding'].includes(rowAction.row.original.status)) { router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`) } else { toast({ title: '접근 제한', - description: '입찰평가중이 아닙니다.', + description: '상세보기가 불가능한 상태입니다.', variant: 'destructive', }) } diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx index 54687cc9..af6b8d43 100644 --- a/lib/bidding/selection/selection-result-form.tsx +++ b/lib/bidding/selection/selection-result-form.tsx @@ -9,8 +9,8 @@ import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { useToast } from '@/hooks/use-toast' -import { saveSelectionResult } from './actions' -import { Loader2, Save, FileText } from 'lucide-react' +import { saveSelectionResult, getSelectionResult } from './actions' +import { Loader2, Save, FileText, Download, X } from 'lucide-react' import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, DropzoneInput } from '@/components/ui/dropzone' const selectionResultSchema = z.object({ @@ -22,12 +22,25 @@ type SelectionResultFormData = z.infer interface SelectionResultFormProps { biddingId: number onSuccess: () => void + readOnly?: boolean } -export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) { +interface AttachmentInfo { + id: number + fileName: string + originalFileName: string + fileSize: number + mimeType: string + filePath: string + uploadedAt: Date | null +} + +export function SelectionResultForm({ biddingId, onSuccess, readOnly = false }: SelectionResultFormProps) { const { toast } = useToast() const [isSubmitting, setIsSubmitting] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(true) const [attachmentFiles, setAttachmentFiles] = React.useState([]) + const [existingAttachments, setExistingAttachments] = React.useState([]) const form = useForm({ resolver: zodResolver(selectionResultSchema), @@ -36,10 +49,53 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor }, }) + // 기존 선정결과 로드 + React.useEffect(() => { + const loadSelectionResult = async () => { + setIsLoading(true) + try { + const result = await getSelectionResult(biddingId) + if (result.success && result.data) { + form.reset({ + summary: result.data.summary || '', + }) + if (result.data.attachments) { + setExistingAttachments(result.data.attachments) + } + } + } catch (error) { + console.error('Failed to load selection result:', error) + toast({ + title: '로드 실패', + description: '선정결과를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + loadSelectionResult() + }, [biddingId, form, toast]) + const removeAttachmentFile = (index: number) => { setAttachmentFiles(prev => prev.filter((_, i) => i !== index)) } + const removeExistingAttachment = (id: number) => { + setExistingAttachments(prev => prev.filter(att => att.id !== id)) + } + + const downloadAttachment = (filePath: string, fileName: string) => { + // 파일 다운로드 (filePath가 절대 경로인 경우) + if (filePath.startsWith('http') || filePath.startsWith('/')) { + window.open(filePath, '_blank') + } else { + // 상대 경로인 경우 + window.open(`/api/files/${filePath}`, '_blank') + } + } + const onSubmit = async (data: SelectionResultFormData) => { setIsSubmitting(true) try { @@ -74,6 +130,22 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor } } + if (isLoading) { + return ( + + + 선정결과 + + +
+ + 로딩 중... +
+
+
+ ) + } + return ( @@ -94,6 +166,7 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor placeholder="선정결과에 대한 요약을 입력해주세요..." className="min-h-[120px]" {...field} + disabled={readOnly} /> @@ -104,35 +177,83 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor {/* 첨부파일 */}
첨부파일 - { - const newFiles = Array.from(files) - setAttachmentFiles(prev => [...prev, ...newFiles]) - }} - onDropRejected={() => { - toast({ - title: "파일 업로드 거부", - description: "파일 크기 및 형식을 확인해주세요.", - variant: "destructive", - }) - }} - > - - - - 파일을 드래그하거나 클릭하여 업로드 - - - PDF, Word, Excel, 이미지 파일 (최대 10MB) - - - - + + {/* 기존 첨부파일 */} + {existingAttachments.length > 0 && ( +
+

기존 첨부파일

+
+ {existingAttachments.map((attachment) => ( +
+
+ +
+

{attachment.originalFileName || attachment.fileName}

+

+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB +

+
+
+
+ + {!readOnly && ( + + )} +
+
+ ))} +
+
+ )} + + {!readOnly && ( + { + const newFiles = Array.from(files) + setAttachmentFiles(prev => [...prev, ...newFiles]) + }} + onDropRejected={() => { + toast({ + title: "파일 업로드 거부", + description: "파일 크기 및 형식을 확인해주세요.", + variant: "destructive", + }) + }} + > + + + + 파일을 드래그하거나 클릭하여 업로드 + + + PDF, Word, Excel, 이미지 파일 (최대 10MB) + + + + + )} {attachmentFiles.length > 0 && (
-

업로드된 파일

+

새로 추가할 파일

{attachmentFiles.map((file, index) => (
- + {!readOnly && ( + + )}
))}
@@ -164,13 +287,15 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor {/* 저장 버튼 */} -
- -
+ {!readOnly && ( +
+ +
+ )}
diff --git a/lib/bidding/selection/vendor-selection-table.tsx b/lib/bidding/selection/vendor-selection-table.tsx index 8570b5b6..40f13ec1 100644 --- a/lib/bidding/selection/vendor-selection-table.tsx +++ b/lib/bidding/selection/vendor-selection-table.tsx @@ -10,9 +10,10 @@ interface VendorSelectionTableProps { biddingId: number bidding: Bidding onRefresh: () => void + readOnly?: boolean } -export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSelectionTableProps) { +export function VendorSelectionTable({ biddingId, bidding, onRefresh, readOnly = false }: VendorSelectionTableProps) { const [vendors, setVendors] = React.useState([]) const [loading, setLoading] = React.useState(true) @@ -59,6 +60,7 @@ export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSe vendors={vendors} onRefresh={onRefresh} onOpenSelectionReasonDialog={() => {}} + readOnly={readOnly} /> diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index a658ee6a..27dae87d 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -18,6 +18,7 @@ import { vendorContacts, vendors } from '@/db/schema' +import { companyConditionResponses } from '@/db/schema/bidding' import { eq, desc, @@ -2196,7 +2197,7 @@ export async function updateBiddingProjectInfo(biddingId: number) { } // 입찰의 PR 아이템 금액 합산하여 bidding 업데이트 -async function updateBiddingAmounts(biddingId: number) { +export async function updateBiddingAmounts(biddingId: number) { try { // 해당 bidding의 모든 PR 아이템들의 금액 합계 계산 const amounts = await db @@ -2214,9 +2215,9 @@ async function updateBiddingAmounts(biddingId: number) { await db .update(biddings) .set({ - targetPrice: totalTargetAmount, - budget: totalBudgetAmount, - finalBidPrice: totalActualAmount, + targetPrice: String(totalTargetAmount), + budget: String(totalBudgetAmount), + finalBidPrice: String(totalActualAmount), updatedAt: new Date() }) .where(eq(biddings.id, biddingId)) @@ -2511,6 +2512,119 @@ export async function deleteBiddingCompanyContact(contactId: number) { } } +// 입찰담당자별 입찰 업체 조회 +export async function getBiddingCompaniesByBidPicId(bidPicId: number) { + try { + const companies = await db + .select({ + biddingId: biddings.id, + biddingNumber: biddings.biddingNumber, + biddingTitle: biddings.title, + companyId: biddingCompanies.companyId, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + updatedAt: biddings.updatedAt, + }) + .from(biddings) + .innerJoin(biddingCompanies, eq(biddings.id, biddingCompanies.biddingId)) + .innerJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(eq(biddings.bidPicId, bidPicId)) + .orderBy(desc(biddings.updatedAt)) + + return { + success: true, + data: companies + } + } catch (error) { + console.error('Failed to get bidding companies by bidPicId:', error) + return { + success: false, + error: '입찰 업체 조회에 실패했습니다.', + data: [] + } + } +} + +// 입찰 업체를 현재 입찰에 추가 (담당자 정보 포함) +export async function addBiddingCompanyFromOtherBidding( + targetBiddingId: number, + sourceBiddingId: number, + companyId: number, + contacts?: Array<{ + contactName: string + contactEmail: string + contactNumber?: string + }> +) { + try { + return await db.transaction(async (tx) => { + // 중복 체크 + const existingCompany = await tx + .select() + .from(biddingCompanies) + .where( + and( + eq(biddingCompanies.biddingId, targetBiddingId), + eq(biddingCompanies.companyId, companyId) + ) + ) + .limit(1) + + if (existingCompany.length > 0) { + return { + success: false, + error: '이미 등록된 업체입니다.' + } + } + + // 1. biddingCompanies 레코드 생성 + const [biddingCompanyResult] = await tx + .insert(biddingCompanies) + .values({ + biddingId: targetBiddingId, + companyId: companyId, + invitationStatus: 'pending', + invitedAt: new Date(), + }) + .returning({ id: biddingCompanies.id }) + + if (!biddingCompanyResult) { + throw new Error('업체 추가에 실패했습니다.') + } + + // 2. 담당자 정보 추가 + if (contacts && contacts.length > 0) { + await tx.insert(biddingCompaniesContacts).values( + contacts.map(contact => ({ + biddingId: targetBiddingId, + vendorId: companyId, + contactName: contact.contactName, + contactEmail: contact.contactEmail, + contactNumber: contact.contactNumber || null, + })) + ) + } + + // 3. company_condition_responses 레코드 생성 + await tx.insert(companyConditionResponses).values({ + biddingCompanyId: biddingCompanyResult.id, + }) + + return { + success: true, + message: '업체가 성공적으로 추가되었습니다.', + data: { id: biddingCompanyResult.id } + } + }) + } catch (error) { + console.error('Failed to add bidding company from other bidding:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.' + } + } +} + export async function updateBiddingConditions( biddingId: number, updates: { @@ -3145,9 +3259,9 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u } } - revalidatePath('/bidding') - revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신 - revalidatePath(`/bidding/${newBidding.id}`) + revalidatePath('/bid-receive') + revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신 + revalidatePath(`/bid-receive/${newBidding.id}`) return { success: true, @@ -3436,9 +3550,10 @@ export async function getBiddingsForSelection(input: GetBiddingsSchema) { // 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회 basicConditions.push( or( - eq(biddings.status, 'bidding_closed'), eq(biddings.status, 'evaluation_of_bidding'), - eq(biddings.status, 'vendor_selected') + eq(biddings.status, 'vendor_selected'), + eq(biddings.status, 'round_increase'), + eq(biddings.status, 'rebidding'), )! ) diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx index 7dd8384e..5afb2b67 100644 --- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx +++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx @@ -382,18 +382,14 @@ export function PrItemsPricingTable({ ) : ( { - let value = e.target.value - if (/^0[0-9]+/.test(value)) { - value = value.replace(/^0+/, '') - if (value === '') value = '0' - } - const numericValue = parseFloat(value) + // 콤마 제거 및 숫자만 허용 + const value = e.target.value.replace(/,/g, '').replace(/[^0-9]/g, '') + const numericValue = Number(value) + updateQuotation( item.id, 'bidUnitPrice', diff --git a/lib/bidding/vendor/export-partners-biddings-to-excel.ts b/lib/bidding/vendor/export-partners-biddings-to-excel.ts new file mode 100644 index 00000000..9e99eeec --- /dev/null +++ b/lib/bidding/vendor/export-partners-biddings-to-excel.ts @@ -0,0 +1,278 @@ +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" +import { PartnersBiddingListItem } from '../detail/service' +import { + biddingStatusLabels, + contractTypeLabels, +} from "@/db/schema" +import { formatDate } from "@/lib/utils" + +/** + * Partners 입찰 목록을 Excel로 내보내기 + * - 계약구분, 진행상태는 라벨(명칭)로 변환 + * - 입찰기간은 submissionStartDate, submissionEndDate 기준 + * - 날짜는 적절한 형식으로 변환 + */ +export async function exportPartnersBiddingsToExcel( + table: Table, + { + filename = "협력업체입찰목록", + onlySelected = false, + }: { + filename?: string + onlySelected?: boolean + } = {} +): Promise { + // 테이블에서 실제 사용 중인 leaf columns 가져오기 + const allColumns = table.getAllLeafColumns() + + // select, actions, attachments 컬럼 제외 + const columns = allColumns.filter( + (col) => !["select", "actions", "attachments"].includes(col.id) + ) + + // 헤더 매핑 (컬럼 id -> Excel 헤더명) + const headerMap: Record = { + biddingNumber: "입찰 No.", + status: "입찰상태", + isUrgent: "긴급여부", + title: "입찰명", + isAttendingMeeting: "사양설명회", + isBiddingParticipated: "입찰 참여의사", + biddingSubmissionStatus: "입찰 제출여부", + contractType: "계약구분", + submissionStartDate: "입찰기간", + contractStartDate: "계약기간", + bidPicName: "입찰담당자", + supplyPicName: "조달담당자", + updatedAt: "최종수정일", + } + + // 헤더 행 생성 + const headerRow = columns.map((col) => { + return headerMap[col.id] || col.id + }) + + // 데이터 행 생성 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => { + const original = row.original + return columns.map((col) => { + const colId = col.id + let value: any + + // 특별 처리 필요한 컬럼들 + switch (colId) { + case "contractType": + // 계약구분: 라벨로 변환 + value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType + break + + case "status": + // 입찰상태: 라벨로 변환 + value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status + break + + case "isUrgent": + // 긴급여부: Yes/No + value = original.isUrgent ? "긴급" : "일반" + break + + case "isAttendingMeeting": + // 사양설명회: 참석/불참/미결정 + if (original.isAttendingMeeting === null) { + value = "해당없음" + } else { + value = original.isAttendingMeeting ? "참석" : "불참" + } + break + + case "isBiddingParticipated": + // 입찰 참여의사: 참여/불참/미결정 + if (original.isBiddingParticipated === null) { + value = "미결정" + } else { + value = original.isBiddingParticipated ? "참여" : "불참" + } + break + + case "biddingSubmissionStatus": + // 입찰 제출여부: 최종제출/제출/미제출 + const finalQuoteAmount = original.finalQuoteAmount + const isFinalSubmission = original.isFinalSubmission + + if (!finalQuoteAmount) { + value = "미제출" + } else if (isFinalSubmission) { + value = "최종제출" + } else { + value = "제출" + } + break + + case "submissionStartDate": + // 입찰기간: submissionStartDate, submissionEndDate 기준 + const startDate = original.submissionStartDate + const endDate = original.submissionEndDate + + if (!startDate || !endDate) { + value = "-" + } else { + const startObj = new Date(startDate) + const endObj = new Date(endDate) + + // KST 변환 (UTC+9) + const formatKst = (d: Date) => { + const kstDate = new Date(d.getTime() + 9 * 60 * 60 * 1000) + return kstDate.toISOString().slice(0, 16).replace('T', ' ') + } + + value = `${formatKst(startObj)} ~ ${formatKst(endObj)}` + } + break + + // case "preQuoteDeadline": + // // 사전견적 마감일: 날짜 형식 + // if (!original.preQuoteDeadline) { + // value = "-" + // } else { + // const deadline = new Date(original.preQuoteDeadline) + // value = deadline.toISOString().slice(0, 16).replace('T', ' ') + // } + // break + + case "contractStartDate": + // 계약기간: contractStartDate, contractEndDate 기준 + const contractStart = original.contractStartDate + const contractEnd = original.contractEndDate + + if (!contractStart || !contractEnd) { + value = "-" + } else { + const startObj = new Date(contractStart) + const endObj = new Date(contractEnd) + value = `${formatDate(startObj, "KR")} ~ ${formatDate(endObj, "KR")}` + } + break + + case "bidPicName": + // 입찰담당자: bidPicName + value = original.bidPicName || "-" + break + + case "supplyPicName": + // 조달담당자: supplyPicName + value = original.supplyPicName || "-" + break + + case "updatedAt": + // 최종수정일: 날짜 시간 형식 + if (original.updatedAt) { + const updated = new Date(original.updatedAt) + value = updated.toISOString().slice(0, 16).replace('T', ' ') + } else { + value = "-" + } + break + + case "biddingNumber": + // 입찰번호: 원입찰번호 포함 + const biddingNumber = original.biddingNumber + const originalBiddingNumber = original.originalBiddingNumber + if (originalBiddingNumber) { + value = `${biddingNumber} (원: ${originalBiddingNumber})` + } else { + value = biddingNumber + } + break + + default: + // 기본값: row.getValue 사용 + value = row.getValue(colId) + + // null/undefined 처리 + if (value == null) { + value = "" + } + + // 객체인 경우 JSON 문자열로 변환 + if (typeof value === "object") { + value = JSON.stringify(value) + } + break + } + + return value + }) + }) + + // 최종 sheetData + const sheetData = [headerRow, ...dataRows] + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, columns.length, filename) +} + +/** + * Excel 파일 생성 및 다운로드 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string +): Promise { + // ExcelJS 워크북/시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 (첫 번째 행) + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + }) + + // 칼럼 너비 자동 조정 + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // 최종 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index a122e87b..6276d1b7 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -285,7 +285,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL cell: ({ row }) => { const isAttending = row.original.isAttendingMeeting if (isAttending === null) { - return
-
+ return
해당없음
} return isAttending ? ( @@ -366,31 +366,31 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL }), // 사전견적 마감일 - columnHelper.accessor('preQuoteDeadline', { - header: '사전견적 마감일', - cell: ({ row }) => { - const deadline = row.original.preQuoteDeadline - if (!deadline) { - return
-
- } + // columnHelper.accessor('preQuoteDeadline', { + // header: '사전견적 마감일', + // cell: ({ row }) => { + // const deadline = row.original.preQuoteDeadline + // if (!deadline) { + // return
-
+ // } - const now = new Date() - const deadlineDate = new Date(deadline) - const isExpired = deadlineDate < now + // const now = new Date() + // const deadlineDate = new Date(deadline) + // const isExpired = deadlineDate < now - return ( -
- - {format(new Date(deadline), "yyyy-MM-dd HH:mm")} - {isExpired && ( - - 마감 - - )} -
- ) - }, - }), + // return ( + //
+ // + // {format(new Date(deadline), "yyyy-MM-dd HH:mm")} + // {isExpired && ( + // + // 마감 + // + // )} + //
+ // ) + // }, + // }), // 계약기간 columnHelper.accessor('contractStartDate', { diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx index 87b1367e..9a2f026c 100644 --- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx +++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx @@ -2,10 +2,12 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Users} from "lucide-react" +import { Users, FileSpreadsheet } from "lucide-react" +import { toast } from "sonner" import { Button } from "@/components/ui/button" import { PartnersBiddingListItem } from '../detail/service' +import { exportPartnersBiddingsToExcel } from './export-partners-biddings-to-excel' interface PartnersBiddingToolbarActionsProps { table: Table @@ -20,6 +22,8 @@ export function PartnersBiddingToolbarActions({ const selectedRows = table.getFilteredSelectedRowModel().rows const selectedBidding = selectedRows.length === 1 ? selectedRows[0].original : null + const [isExporting, setIsExporting] = React.useState(false) + const handleSpecificationMeetingClick = () => { if (selectedBidding && setRowAction) { setRowAction({ @@ -29,8 +33,36 @@ export function PartnersBiddingToolbarActions({ } } + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + try { + setIsExporting(true) + await exportPartnersBiddingsToExcel(table, { + filename: "협력업체입찰목록", + onlySelected: false, + }) + toast.success("Excel 파일이 다운로드되었습니다.") + } catch (error) { + console.error("Excel export error:", error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + } finally { + setIsExporting(false) + } + }, [table]) + return (
+ {/* Excel 내보내기 버튼 */} + + + + + + + 검색 결과가 없습니다. + + {paymentTermsOptions.map((option) => ( + { + setFormData(prev => ({ ...prev, paymentDelivery: option.code })) + }} + > + + {option.code} {option.description && `(${option.description})`} + + ))} + { + setFormData(prev => ({ ...prev, paymentDelivery: '납품완료일로부터 60일 이내 지급' })) + }} + > + + 60일 이내 + + { + setFormData(prev => ({ ...prev, paymentDelivery: '추가조건' })) + }} + > + + 추가조건 + + + + + + {formData.paymentDelivery === '추가조건' && (
- {/* 지불조건 -> 세금조건 (지불조건 삭제됨) */} + {/*세금조건*/}
- {/* 지불조건 필드 삭제됨 -
- - -
- */}
- + + + + + + + + + 검색 결과가 없습니다. + + {TAX_CONDITIONS.map((condition) => ( + { + setFormData(prev => ({ ...prev, taxType: condition.code })) + }} + > + + {condition.name} + + ))} + + + + +
@@ -1266,79 +1393,178 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { {/* 인도조건 */}
- + + + + + + + + + 검색 결과가 없습니다. + + {incotermsOptions.length > 0 ? ( + incotermsOptions.map((option) => ( + { + setFormData(prev => ({ ...prev, deliveryTerm: option.code })) + }} + > + + {option.code} {option.description && `(${option.description})`} + + )) + ) : ( + + 로딩중... + + )} + + + + +
{/* 선적지 */}
- + + + + + + + + + 검색 결과가 없습니다. + + {shippingPlaces.length > 0 ? ( + shippingPlaces.map((place) => ( + { + setFormData(prev => ({ ...prev, shippingLocation: place.code })) + }} + > + + {place.code} {place.description && `(${place.description})`} + + )) + ) : ( + + 로딩중... + + )} + + + + +
{/* 하역지 */}
- + + + + + + + + + 검색 결과가 없습니다. + + {destinationPlaces.length > 0 ? ( + destinationPlaces.map((place) => ( + { + setFormData(prev => ({ ...prev, dischargeLocation: place.code })) + }} + > + + {place.code} {place.description && `(${place.description})`} + + )) + ) : ( + + 로딩중... + + )} + + + + +
{/* 계약납기일 */} diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index 15e5c926..e5fc6cf2 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -30,6 +30,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { ProjectSelector } from '@/components/ProjectSelector' import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single' import { MaterialSearchItem } from '@/lib/material/material-group-service' +import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single' +import { ProcurementSearchItem } from '@/components/common/selectors/procurement-item/procurement-item-service' interface ContractItem { id?: number @@ -174,7 +176,7 @@ export function ContractItemsTable({ const errors: string[] = [] for (let index = 0; index < localItems.length; index++) { const item = localItems[index] - if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) + // if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`) @@ -271,6 +273,34 @@ export function ContractItemsTable({ onItemsChange(updatedItems) } + // 1회성 품목 선택 시 행 추가 + const handleOneTimeItemSelect = (item: ProcurementSearchItem | null) => { + if (!item) return + + const newItem: ContractItem = { + projectId: null, + itemCode: item.itemCode, + itemInfo: item.itemName, + materialGroupCode: '', + materialGroupDescription: '', + specification: item.specification || '', + quantity: 0, + quantityUnit: item.unit || 'EA', + totalWeight: 0, + weightUnit: 'KG', + contractDeliveryDate: '', + contractUnitPrice: 0, + contractAmount: 0, + contractCurrency: 'KRW', + isSelected: false + } + + const updatedItems = [...localItems, newItem] + setLocalItems(updatedItems) + onItemsChange(updatedItems) + toast.success('1회성 품목이 추가되었습니다.') + } + // 일괄입력 적용 const applyBatchInput = () => { if (localItems.length === 0) { @@ -382,6 +412,17 @@ export function ContractItemsTable({ 행 추가 +