summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/cpvw-service.ts236
-rw-r--r--lib/basic-contract/service.ts267
-rw-r--r--lib/basic-contract/sslvw-service.ts126
-rw-r--r--lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx224
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx39
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-table.tsx1
-rw-r--r--lib/bidding/actions.ts8
-rw-r--r--lib/bidding/approval-actions.ts2
-rw-r--r--lib/bidding/detail/service.ts151
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx5
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx195
-rw-r--r--lib/bidding/handlers.ts12
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx54
-rw-r--r--lib/bidding/list/export-biddings-to-excel.ts212
-rw-r--r--lib/bidding/manage/export-bidding-items-to-excel.ts161
-rw-r--r--lib/bidding/manage/import-bidding-items-from-excel.ts271
-rw-r--r--lib/bidding/manage/project-utils.ts87
-rw-r--r--lib/bidding/selection/actions.ts69
-rw-r--r--lib/bidding/selection/bidding-info-card.tsx2
-rw-r--r--lib/bidding/selection/bidding-item-table.tsx192
-rw-r--r--lib/bidding/selection/bidding-selection-detail-content.tsx11
-rw-r--r--lib/bidding/selection/biddings-selection-table.tsx6
-rw-r--r--lib/bidding/selection/selection-result-form.tsx213
-rw-r--r--lib/bidding/selection/vendor-selection-table.tsx4
-rw-r--r--lib/bidding/service.ts133
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx18
-rw-r--r--lib/bidding/vendor/export-partners-biddings-to-excel.ts278
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx48
-rw-r--r--lib/bidding/vendor/partners-bidding-toolbar-actions.tsx34
-rw-r--r--lib/general-contracts/detail/general-contract-basic-info.tsx478
-rw-r--r--lib/general-contracts/detail/general-contract-items-table.tsx43
-rw-r--r--lib/general-contracts/service.ts11
-rw-r--r--lib/procurement-items/service.ts15
-rw-r--r--lib/soap/ecc/mapper/bidding-and-pr-mapper.ts15
34 files changed, 3077 insertions, 544 deletions
diff --git a/lib/basic-contract/cpvw-service.ts b/lib/basic-contract/cpvw-service.ts
new file mode 100644
index 00000000..6d249002
--- /dev/null
+++ b/lib/basic-contract/cpvw-service.ts
@@ -0,0 +1,236 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// CPVW_WAB_QUST_LIST_VIEW 테이블 데이터 타입 (실제 테이블 구조에 맞게 조정 필요)
+export interface CPVWWabQustListView {
+ [key: string]: string | number | Date | null | undefined
+}
+
+// 테스트 환경용 폴백 데이터 (실제 CPVW_WAB_QUST_LIST_VIEW 테이블 구조에 맞춤)
+const FALLBACK_TEST_DATA: CPVWWabQustListView[] = [
+ {
+ REG_NO: '1030',
+ INQ_TP: 'OC',
+ INQ_TP_DSC: '해외계약',
+ TIT: 'Contrack of Sale',
+ REQ_DGR: '2',
+ REQR_NM: '김원식',
+ REQ_DT: '20130829',
+ REVIEW_TERM_DT: '20130902',
+ RVWR_NM: '김미정',
+ CNFMR_NM: '안한진',
+ APPR_NM: '염정훈',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '검토중',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1076',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'CAISSON PIPE 복관 계약서 검토 요청件',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130821',
+ REVIEW_TERM_DT: '20130826',
+ RVWR_NM: '이택준',
+ CNFMR_NM: '이택준',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1100',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: '(7102) HVAC 작업계약',
+ REQ_DGR: '1',
+ REQR_NM: '신동동',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1105',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'Plate 가공계약서 검토 요청건',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '백영국',
+ CNFMR_NM: '백영국',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1106',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件',
+ REQ_DGR: '1',
+ REQR_NM: '성기승',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130830',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ }
+]
+
+const normalizeOracleRows = (rows: Array<Record<string, unknown>>): CPVWWabQustListView[] => {
+ return rows.map((item) => {
+ const convertedItem: CPVWWabQustListView = {}
+ for (const [key, value] of Object.entries(item)) {
+ if (value instanceof Date) {
+ convertedItem[key] = value
+ } else if (value === null) {
+ convertedItem[key] = null
+ } else {
+ convertedItem[key] = String(value)
+ }
+ }
+ return convertedItem
+ })
+}
+
+/**
+ * CPVW_WAB_QUST_LIST_VIEW 테이블 전체 조회
+ * @returns 테이블 데이터 배열
+ */
+export async function getCPVWWabQustListViewData(): Promise<{
+ success: boolean
+ data: CPVWWabQustListView[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getCPVWWabQustListViewData] CPVW_WAB_QUST_LIST_VIEW 테이블 조회 시작...')
+
+ const result = await oracleKnex.raw(`
+ SELECT *
+ FROM CPVW_WAB_QUST_LIST_VIEW
+ WHERE ROWNUM < 100
+ ORDER BY 1
+ `)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+
+ console.log(`✅ [getCPVWWabQustListViewData] 조회 성공 - ${rows.length}건`)
+
+ // 데이터 타입 변환 (필요에 따라 조정)
+ const cleanedResult = normalizeOracleRows(rows)
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getCPVWWabQustListViewData] 오류:', error)
+ console.log('🔄 [getCPVWWabQustListViewData] 폴백 테스트 데이터 사용')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
+
+export async function getCPVWWabQustListViewByRegNo(regNo: string): Promise<{
+ success: boolean
+ data?: CPVWWabQustListView
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ if (!regNo) {
+ return {
+ success: false,
+ error: 'REG_NO는 필수입니다.'
+ }
+ }
+
+ try {
+ console.log(`[getCPVWWabQustListViewByRegNo] REG_NO=${regNo} 조회`)
+ const result = await oracleKnex.raw(
+ `
+ SELECT *
+ FROM CPVW_WAB_QUST_LIST_VIEW
+ WHERE REG_NO = :regNo
+ `,
+ { regNo }
+ )
+
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+ const cleanedResult = normalizeOracleRows(rows)
+
+ if (cleanedResult.length === 0) {
+ // 데이터가 없을 때 폴백 테스트 데이터에서 찾기
+ console.log(`[getCPVWWabQustListViewByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
+ return {
+ success: false,
+ error: '해당 REG_NO에 대한 데이터가 없습니다.'
+ }
+ }
+
+ return {
+ success: true,
+ data: cleanedResult[0],
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('[getCPVWWabQustListViewByRegNo] 오류:', error)
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+
+ // 오류 발생 시 폴백 테스트 데이터에서 찾기
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.'
+ }
+ }
+}
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
index 6f4e5d53..12278c54 100644
--- a/lib/basic-contract/service.ts
+++ b/lib/basic-contract/service.ts
@@ -2862,6 +2862,10 @@ export async function requestLegalReviewAction(
}
}
+// ⚠️ SSLVW(법무관리시스템) PRGS_STAT_DSC 문자열을 그대로 저장하는 함수입니다.
+// - 상태 텍스트 및 완료 여부는 외부 시스템에 의존하므로 신뢰도가 100%는 아니고,
+// - 여기에서 관리하는 값들은 UI 표시/참고용으로만 사용해야 합니다.
+// - 최종 승인 차단 등 핵심 비즈니스 로직에서는 SSLVW 쪽 완료 시간을 직접 신뢰하지 않습니다.
const persistLegalReviewStatus = async ({
contractId,
regNo,
@@ -2904,6 +2908,121 @@ const persistLegalReviewStatus = async ({
}
/**
+ * 준법문의 요청 서버 액션
+ */
+export async function requestComplianceInquiryAction(
+ contractIds: number[]
+): Promise<{ success: boolean; message: string }> {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select({
+ id: basicContractView.id,
+ complianceReviewRequestedAt: basicContractView.complianceReviewRequestedAt,
+ })
+ .from(basicContractView)
+ .where(inArray(basicContractView.id, contractIds))
+
+ if (contracts.length === 0) {
+ return {
+ success: false,
+ message: "선택된 계약서를 찾을 수 없습니다."
+ }
+ }
+
+ // 준법문의 요청 가능한 계약서 필터링 (이미 요청되지 않은 것만)
+ const eligibleContracts = contracts.filter(contract =>
+ !contract.complianceReviewRequestedAt
+ )
+
+ if (eligibleContracts.length === 0) {
+ return {
+ success: false,
+ message: "준법문의 요청 가능한 계약서가 없습니다."
+ }
+ }
+
+ const currentDate = new Date()
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ for (const contract of eligibleContracts) {
+ await tx
+ .update(basicContract)
+ .set({
+ complianceReviewRequestedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(basicContract.id, contract.id))
+ }
+ })
+
+ revalidateTag("basic-contracts")
+
+ return {
+ success: true,
+ message: `${eligibleContracts.length}건의 준법문의 요청이 완료되었습니다.`
+ }
+}
+
+/**
+ * 준법문의 상태 저장 (준법문의 전용 필드 사용)
+ */
+const persistComplianceReviewStatus = async ({
+ contractId,
+ regNo,
+ progressStatus,
+}: {
+ contractId: number
+ regNo: string
+ progressStatus: string
+}) => {
+ const now = new Date()
+
+ // 완료 상태 확인 (법무검토와 동일한 패턴)
+ // ⚠️ CPVW PRGS_STAT_DSC 문자열을 기반으로 한 best-effort 휴리스틱입니다.
+ // - 외부 시스템의 상태 텍스트에 의존하므로 신뢰도가 100%는 아니고,
+ // - 여기에서 설정하는 완료 시간(complianceReviewCompletedAt)은 UI 표시용으로만 사용해야 합니다.
+ // - 버튼 활성화, 서버 액션 차단, 필터 조건 등 핵심 비즈니스 로직에서는
+ // 이 값을 신뢰하지 않도록 합니다.
+ // 완료 상태 확인 (법무검토와 동일한 패턴)
+ const isCompleted = progressStatus && (
+ progressStatus.includes('완료') ||
+ progressStatus.includes('승인') ||
+ progressStatus.includes('종료')
+ )
+
+ await db.transaction(async (tx) => {
+ // 준법문의 상태 업데이트 (준법문의 전용 필드 사용)
+ const updateData: any = {
+ complianceReviewRegNo: regNo,
+ complianceReviewProgressStatus: progressStatus,
+ updatedAt: now,
+ }
+
+ // 완료 상태인 경우 완료일 설정
+ if (isCompleted) {
+ updateData.complianceReviewCompletedAt = now
+ }
+
+ await tx
+ .update(basicContract)
+ .set(updateData)
+ .where(eq(basicContract.id, contractId))
+ })
+
+ revalidateTag("basic-contracts")
+}
+
+/**
* SSLVW 데이터로부터 법무검토 상태 업데이트
* @param sslvwData 선택된 SSLVW 데이터 배열
* @param selectedContractIds 선택된 계약서 ID 배열
@@ -3033,6 +3152,137 @@ export async function updateLegalReviewStatusFromSSLVW(
}
}
+/**
+ * CPVW 데이터로부터 준법문의 상태 업데이트
+ * @param cpvwData 선택된 CPVW 데이터 배열
+ * @param selectedContractIds 선택된 계약서 ID 배열
+ * @returns 성공 여부 및 메시지
+ */
+export async function updateComplianceReviewStatusFromCPVW(
+ cpvwData: Array<{ REG_NO?: string; reg_no?: string; PRGS_STAT_DSC?: string; prgs_stat_dsc?: string; [key: string]: any }>,
+ selectedContractIds: number[]
+): Promise<{ success: boolean; message: string; updatedCount: number; errors: string[] }> {
+ try {
+ console.log(`[updateComplianceReviewStatusFromCPVW] CPVW 데이터로부터 준법문의 상태 업데이트 시작`)
+
+ if (!cpvwData || cpvwData.length === 0) {
+ return {
+ success: false,
+ message: 'CPVW 데이터가 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (!selectedContractIds || selectedContractIds.length === 0) {
+ return {
+ success: false,
+ message: '선택된 계약서가 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (selectedContractIds.length !== 1) {
+ return {
+ success: false,
+ message: '한 개의 계약서만 선택해 주세요.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (cpvwData.length !== 1) {
+ return {
+ success: false,
+ message: '준법문의 시스템 데이터도 한 건만 선택해 주세요.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ const contractId = selectedContractIds[0]
+ const cpvwItem = cpvwData[0]
+ const regNo = String(
+ cpvwItem.REG_NO ??
+ cpvwItem.reg_no ??
+ cpvwItem.RegNo ??
+ ''
+ ).trim()
+ const progressStatus = String(
+ cpvwItem.PRGS_STAT_DSC ??
+ cpvwItem.prgs_stat_dsc ??
+ cpvwItem.PrgsStatDsc ??
+ ''
+ ).trim()
+
+ if (!regNo) {
+ return {
+ success: false,
+ message: 'REG_NO 값을 찾을 수 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (!progressStatus) {
+ return {
+ success: false,
+ message: 'PRGS_STAT_DSC 값을 찾을 수 없습니다.',
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ const contract = await db
+ .select({
+ id: basicContract.id,
+ complianceReviewRegNo: basicContract.complianceReviewRegNo,
+ })
+ .from(basicContract)
+ .where(eq(basicContract.id, contractId))
+ .limit(1)
+
+ if (!contract[0]) {
+ return {
+ success: false,
+ message: `계약서(${contractId})를 찾을 수 없습니다.`,
+ updatedCount: 0,
+ errors: []
+ }
+ }
+
+ if (contract[0].complianceReviewRegNo && contract[0].complianceReviewRegNo !== regNo) {
+ console.warn(`[updateComplianceReviewStatusFromCPVW] REG_NO가 변경됩니다: ${contract[0].complianceReviewRegNo} -> ${regNo}`)
+ }
+
+ // 준법문의 상태 업데이트
+ await persistComplianceReviewStatus({
+ contractId,
+ regNo,
+ progressStatus,
+ })
+
+ console.log(`[updateComplianceReviewStatusFromCPVW] 완료: 계약서 ${contractId}, REG_NO ${regNo}, 상태 ${progressStatus}`)
+
+ return {
+ success: true,
+ message: '준법문의 상태가 업데이트되었습니다.',
+ updatedCount: 1,
+ errors: []
+ }
+
+ } catch (error) {
+ console.error('[updateComplianceReviewStatusFromCPVW] 오류:', error)
+ return {
+ success: false,
+ message: '준법문의 상태 업데이트 중 오류가 발생했습니다.',
+ updatedCount: 0,
+ errors: [error instanceof Error ? error.message : '알 수 없는 오류']
+ }
+ }
+}
+
export async function refreshLegalReviewStatusFromOracle(contractId: number): Promise<{
success: boolean
message: string
@@ -3274,12 +3524,9 @@ export async function processBuyerSignatureAction(
}
}
- if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) {
- return {
- success: false,
- message: "법무검토가 완료되지 않았습니다."
- }
- }
+ // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로
+ // 여기서는 legalReviewCompletedAt 기반으로 최종승인을 막지 않습니다.
+ // (법무 상태는 UI에서 참고 정보로만 사용)
// 파일 저장 로직 (기존 파일 덮어쓰기)
const saveResult = await saveBuffer({
@@ -3373,9 +3620,9 @@ export async function prepareFinalApprovalAction(
if (contract.completedAt !== null || !contract.signedFilePath) {
return false
}
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false
- }
+ // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로
+ // 여기서는 legalReviewCompletedAt 기반으로 필터링하지 않습니다.
+ // (법무 상태는 UI에서 참고 정보로만 사용)
return true
})
@@ -3949,6 +4196,8 @@ export async function saveGtcDocumentAction({
buyerSignedAt: null,
legalReviewRequestedAt: null,
legalReviewCompletedAt: null,
+ complianceReviewRequestedAt: null,
+ complianceReviewCompletedAt: null,
updatedAt: new Date()
})
.where(eq(basicContract.id, documentId))
diff --git a/lib/basic-contract/sslvw-service.ts b/lib/basic-contract/sslvw-service.ts
index 38ecb67d..08b43f82 100644
--- a/lib/basic-contract/sslvw-service.ts
+++ b/lib/basic-contract/sslvw-service.ts
@@ -10,18 +10,89 @@ export interface SSLVWPurInqReq {
// 테스트 환경용 폴백 데이터
const FALLBACK_TEST_DATA: SSLVWPurInqReq[] = [
{
- id: 1,
- request_number: 'REQ001',
- status: 'PENDING',
- created_date: new Date('2025-01-01'),
- description: '테스트 요청 1'
+ REG_NO: '1030',
+ INQ_TP: 'OC',
+ INQ_TP_DSC: '해외계약',
+ TIT: 'Contrack of Sale',
+ REQ_DGR: '2',
+ REQR_NM: '김원식',
+ REQ_DT: '20130829',
+ REVIEW_TERM_DT: '20130902',
+ RVWR_NM: '김미정',
+ CNFMR_NM: '안한진',
+ APPR_NM: '염정훈',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '검토중이라고',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
},
{
- id: 2,
- request_number: 'REQ002',
- status: 'APPROVED',
- created_date: new Date('2025-01-02'),
- description: '테스트 요청 2'
+ REG_NO: '1076',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'CAISSON PIPE 복관 계약서 검토 요청件',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130821',
+ REVIEW_TERM_DT: '20130826',
+ RVWR_NM: '이택준',
+ CNFMR_NM: '이택준',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1100',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: '(7102) HVAC 작업계약',
+ REQ_DGR: '1',
+ REQR_NM: '신동동',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1105',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'Plate 가공계약서 검토 요청건',
+ REQ_DGR: '1',
+ REQR_NM: '서권환',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130829',
+ RVWR_NM: '백영국',
+ CNFMR_NM: '백영국',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
+ },
+ {
+ REG_NO: '1106',
+ INQ_TP: 'IC',
+ INQ_TP_DSC: '국내계약',
+ TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件',
+ REQ_DGR: '1',
+ REQR_NM: '성기승',
+ REQ_DT: '20130826',
+ REVIEW_TERM_DT: '20130830',
+ RVWR_NM: '이두리',
+ CNFMR_NM: '이두리',
+ APPR_NM: '전상용',
+ PRGS_STAT: 'E',
+ PRGS_STAT_DSC: '완료',
+ REGR_DPTCD: 'D602058000',
+ REGR_DEPTNM: '구매1팀(사외계약)'
}
]
@@ -89,6 +160,7 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
success: boolean
data?: SSLVWPurInqReq
error?: string
+ isUsingFallback?: boolean
}> {
if (!regNo) {
return {
@@ -112,6 +184,21 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
const cleanedResult = normalizeOracleRows(rows)
if (cleanedResult.length === 0) {
+ // 데이터가 없을 때 폴백 테스트 데이터에서 찾기
+ console.log(`[getSSLVWPurInqReqByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
return {
success: false,
error: '해당 REG_NO에 대한 데이터가 없습니다.'
@@ -120,10 +207,27 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{
return {
success: true,
- data: cleanedResult[0]
+ data: cleanedResult[0],
+ isUsingFallback: false
}
} catch (error) {
console.error('[getSSLVWPurInqReqByRegNo] 오류:', error)
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`)
+
+ // 오류 발생 시 폴백 테스트 데이터에서 찾기
+ const fallbackData = FALLBACK_TEST_DATA.find(item =>
+ String(item.REG_NO) === String(regNo)
+ )
+
+ if (fallbackData) {
+ console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`)
+ return {
+ success: true,
+ data: fallbackData,
+ isUsingFallback: true
+ }
+ }
+
return {
success: false,
error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.'
diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
index 575582cf..8f945bbd 100644
--- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
+++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
@@ -18,9 +18,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
-import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW } from "../service"
+import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW, updateComplianceReviewStatusFromCPVW, requestComplianceInquiryAction } from "../service"
import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog"
import { SSLVWPurInqReqDialog } from "@/components/common/legal/sslvw-pur-inq-req-dialog"
+import { CPVWWabQustListViewDialog } from "@/components/common/legal/cpvw-wab-qust-list-view-dialog"
import { prepareRedFlagResolutionApproval, requestRedFlagResolution } from "@/lib/compliance/red-flag-resolution"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
@@ -35,6 +36,7 @@ interface RedFlagResolutionState {
interface BasicContractDetailTableToolbarActionsProps {
table: Table<BasicContractView>
gtcData?: Record<number, { gtcDocumentId: number | null; hasComments: boolean }>
+ agreementCommentData?: Record<number, { hasComments: boolean; commentCount: number }>
redFlagData?: Record<number, boolean>
redFlagResolutionData?: Record<number, RedFlagResolutionState>
isComplianceTemplate?: boolean
@@ -43,6 +45,7 @@ interface BasicContractDetailTableToolbarActionsProps {
export function BasicContractDetailTableToolbarActions({
table,
gtcData = {},
+ agreementCommentData = {},
redFlagData = {},
redFlagResolutionData = {},
isComplianceTemplate = false
@@ -81,24 +84,26 @@ export function BasicContractDetailTableToolbarActions({
if (contract.completedAt !== null || !contract.signedFilePath) {
return false;
}
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false;
- }
+ // ⚠️ 법무/준법문의 완료 여부는 SSLVW/CPVW 상태 및 완료 시간에 의존하므로,
+ // 여기서는 legalReviewCompletedAt / complianceReviewCompletedAt 기반으로
+ // 최종 승인 버튼을 막지 않습니다. (상태/시간은 UI 참고용으로만 사용)
return true;
});
- // 법무검토 요청 가능 여부
- // 1. 협의 완료됨 (negotiationCompletedAt 있음) OR
- // 2. 협의 없음 (코멘트 없음, hasComments: false)
+ // 법무검토 요청 가능 여부 (준법서약 템플릿이 아닐 때만)
+ // 1. 협력업체 서명 완료 (vendorSignedAt 있음)
+ // 2. 협의 완료됨 (negotiationCompletedAt 있음) OR
+ // 3. 협의 없음 (코멘트 없음, hasComments: false)
// 협의 중 (negotiationCompletedAt 없고 코멘트 있음)은 불가
- const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => {
+ const canRequestLegalReview = !isComplianceTemplate && hasSelectedRows && selectedRows.some(row => {
const contract = row.original;
- // 이미 법무검토 요청된 계약서는 제외
- if (contract.legalReviewRequestedAt) {
- return false;
- }
- // 이미 최종승인 완료된 계약서는 제외
- if (contract.completedAt) {
+
+ // 필수 조건 확인: 최종승인 미완료, 법무검토 미요청, 협력업체 서명 완료
+ if (
+ contract.legalReviewRequestedAt ||
+ contract.completedAt ||
+ !contract.vendorSignedAt
+ ) {
return false;
}
@@ -123,6 +128,48 @@ export function BasicContractDetailTableToolbarActions({
return false;
});
+ // 준법문의 버튼 활성화 가능 여부
+ // 1. 협력업체 서명 완료 (vendorSignedAt 있음)
+ // 2. 협의 완료됨 (negotiationCompletedAt 있음) OR 협의 없음 (코멘트 없음)
+ // 3. 레드플래그 해소됨 (redFlagResolutionData에서 resolved 상태)
+ // 4. 이미 준법문의 요청되지 않음 (complianceReviewRequestedAt 없음)
+ const canRequestComplianceInquiry = hasSelectedRows && selectedRows.some(row => {
+ const contract = row.original;
+
+ // 필수 조건 확인: 준법서약 템플릿, 최종승인 미완료, 협력업체 서명 완료, 준법문의 미요청
+ if (
+ !isComplianceTemplate ||
+ contract.completedAt ||
+ !contract.vendorSignedAt ||
+ contract.complianceReviewRequestedAt
+ ) {
+ return false;
+ }
+
+ // 협의 완료 확인
+ // 협의 완료된 경우 → 가능
+ if (contract.negotiationCompletedAt) {
+ // 협의 완료됨, 레드플래그만 확인하면 됨
+ } else {
+ // 협의 완료되지 않은 경우: 코멘트가 없으면 협의 없음으로 간주하여 가능
+ const commentData = agreementCommentData[contract.id];
+ if (commentData && commentData.hasComments) {
+ // 코멘트가 있으면 협의 중이므로 불가
+ return false;
+ }
+ // 코멘트가 없으면 협의 없음으로 간주하여 가능
+ }
+
+ // 레드플래그 해소 확인
+ const resolution = redFlagResolutionData[contract.id];
+ // 레드플래그가 있는 경우, 해소되어야 함
+ if (redFlagData[contract.id] === true && !resolution?.resolved) {
+ return false;
+ }
+
+ return true;
+ });
+
// 필터링된 계약서들 계산
const resendContracts = selectedRows.map(row => row.original)
@@ -394,6 +441,47 @@ export function BasicContractDetailTableToolbarActions({
}
}
+ // CPVW 데이터 선택 확인 핸들러
+ const handleCPVWConfirm = async (selectedCPVWData: any[]) => {
+ if (!selectedCPVWData || selectedCPVWData.length === 0) {
+ toast.error("선택된 데이터가 없습니다.")
+ return
+ }
+
+ if (selectedRows.length !== 1) {
+ toast.error("계약서 한 건을 선택해주세요.")
+ return
+ }
+
+ try {
+ setLoading(true)
+
+ // 선택된 계약서 ID들 추출
+ const selectedContractIds = selectedRows.map(row => row.original.id)
+
+ // 서버 액션 호출
+ const result = await updateComplianceReviewStatusFromCPVW(selectedCPVWData, selectedContractIds)
+
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ table.toggleAllPageRowsSelected(false)
+ } else {
+ toast.error(result.message)
+ }
+
+ if (result.errors && result.errors.length > 0) {
+ toast.warning(`일부 처리 실패: ${result.errors.join(', ')}`)
+ }
+
+ } catch (error) {
+ console.error('CPVW 확인 처리 실패:', error)
+ toast.error('준법문의 상태 업데이트 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
// 빠른 승인 (서명 없이)
const confirmQuickApproval = async () => {
setLoading(true)
@@ -541,9 +629,26 @@ export function BasicContractDetailTableToolbarActions({
const complianceInquiryUrl = 'http://60.101.207.55/Inquiry/Write/InquiryWrite.aspx'
// 법무검토 요청 / 준법문의
- const handleRequestLegalReview = () => {
+ const handleRequestLegalReview = async () => {
if (isComplianceTemplate) {
- window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer')
+ // 준법문의: 요청일 기록 후 외부 URL 열기
+ const selectedContractIds = selectedRows.map(row => row.original.id)
+ try {
+ setLoading(true)
+ const result = await requestComplianceInquiryAction(selectedContractIds)
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer')
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('준법문의 요청 처리 실패:', error)
+ toast.error('준법문의 요청 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
return
}
setLegalReviewDialog(true)
@@ -617,31 +722,72 @@ export function BasicContractDetailTableToolbarActions({
</span>
</Button>
- {/* 법무검토 버튼 (SSLVW 데이터 조회) */}
- <SSLVWPurInqReqDialog
- onConfirm={handleSSLVWConfirm}
- requireSingleSelection
- triggerDisabled={selectedRows.length !== 1 || loading}
- triggerTitle={
- selectedRows.length !== 1
- ? "계약서 한 건을 선택해주세요"
- : undefined
- }
- />
+ {/* 법무검토 버튼 (SSLVW 데이터 조회) - 준법서약 템플릿이 아닐 때만 표시 */}
+ {!isComplianceTemplate && (
+ <SSLVWPurInqReqDialog
+ onConfirm={handleSSLVWConfirm}
+ requireSingleSelection
+ triggerDisabled={selectedRows.length !== 1 || loading}
+ triggerTitle={
+ selectedRows.length !== 1
+ ? "계약서 한 건을 선택해주세요"
+ : undefined
+ }
+ />
+ )}
+
+ {/* 준법문의 요청 데이터 조회 버튼 (준법서약 템플릿만) */}
+ {isComplianceTemplate && (
+ <CPVWWabQustListViewDialog
+ onConfirm={handleCPVWConfirm}
+ requireSingleSelection
+ triggerDisabled={selectedRows.length !== 1 || loading}
+ triggerTitle={
+ selectedRows.length !== 1
+ ? "계약서 한 건을 선택해주세요"
+ : undefined
+ }
+ />
+ )}
{/* 법무검토 요청 / 준법문의 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleRequestLegalReview}
- className="gap-2"
- title={isComplianceTemplate ? "준법문의 링크로 이동" : "법무검토 요청 링크 선택"}
- >
- <FileText className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- {isComplianceTemplate ? "준법문의" : "법무검토 요청"}
- </span>
- </Button>
+ {isComplianceTemplate ? (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRequestLegalReview}
+ className="gap-2"
+ disabled={!canRequestComplianceInquiry || loading}
+ title={
+ !canRequestComplianceInquiry
+ ? "협력업체 서명 완료, 협의 완료, 레드플래그 해소가 필요합니다"
+ : "준법문의 링크로 이동"
+ }
+ >
+ <FileText className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 준법문의
+ </span>
+ </Button>
+ ) : (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRequestLegalReview}
+ className="gap-2"
+ disabled={!canRequestLegalReview || loading}
+ title={
+ !canRequestLegalReview
+ ? "협력업체 서명 완료 및 협의 완료가 필요합니다"
+ : "법무검토 요청 링크 선택"
+ }
+ >
+ <FileText className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 법무검토 요청
+ </span>
+ </Button>
+ )}
{/* 최종승인 버튼 */}
<Button
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
index aab808b8..de6ba1a9 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
@@ -553,8 +553,8 @@ export function getDetailColumns({
minSize: 130,
},
- // 법무검토 상태
- {
+ // 법무검토 상태 (준법서약 템플릿이 아닐 때만 표시)
+ ...(!isComplianceTemplate ? [{
accessorKey: "legalReviewStatus",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="법무검토 상태" />
@@ -571,7 +571,30 @@ export function getDetailColumns({
return <div className="text-sm text-gray-400">-</div>
},
minSize: 140,
+ }] : []),
+
+ // 준법문의 상태 (준법서약 템플릿일 때만 표시)
+ ...(isComplianceTemplate ? [{
+ accessorKey: "complianceReviewStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="준법문의 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("complianceReviewStatus") as string | null
+
+ // PRGS_STAT_DSC 연동값 우선 표시
+ if (status) {
+ return <div className="text-sm text-gray-800">{status}</div>
+ }
+
+ // 동기화된 값이 없으면 빈 값 처리
+ return <div className="text-sm text-gray-400">-</div>
+ },
+ minSize: 140,
},
+ // Red Flag 컬럼들 (준법서약 템플릿일 때만 표시)
+ redFlagColumn,
+ redFlagResolutionColumn] : []),
// 계약완료일
{
@@ -659,17 +682,5 @@ export function getDetailColumns({
actionsColumn,
]
- // 준법서약 템플릿인 경우 Red Flag 컬럼과 해제 컬럼을 법무검토 상태 뒤에 추가
- if (isComplianceTemplate) {
- const legalReviewStatusIndex = baseColumns.findIndex((col) => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- return (col as any).accessorKey === 'legalReviewStatus'
- })
-
- if (legalReviewStatusIndex !== -1) {
- baseColumns.splice(legalReviewStatusIndex + 1, 0, redFlagColumn, redFlagResolutionColumn)
- }
- }
-
return baseColumns
} \ No newline at end of file
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
index cface6b3..c6fe1cdd 100644
--- a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
@@ -241,6 +241,7 @@ type RedFlagResolutionState = {
<BasicContractDetailTableToolbarActions
table={table}
gtcData={gtcData}
+ agreementCommentData={agreementCommentData}
redFlagData={redFlagData}
redFlagResolutionData={redFlagResolutionData}
isComplianceTemplate={isComplianceTemplate}
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
index 4e7da36c..cc246ee7 100644
--- a/lib/bidding/actions.ts
+++ b/lib/bidding/actions.ts
@@ -125,6 +125,11 @@ export async function transmitToContract(biddingId: number, userId: number) {
const contractNumber = await generateContractNumber(safeUserId, biddingData.contractType)
console.log('Generated contractNumber:', contractNumber)
+ // 연동제 여부 변환 (boolean -> Y/N)
+ const interlockingSystem = biddingCondition?.isPriceAdjustmentApplicable
+ ? 'Y'
+ : (biddingCondition?.isPriceAdjustmentApplicable === false ? 'N' : null)
+
// general-contract 생성 (발주비율 계산된 최종 금액 사용)
const contractResult = await db.insert(generalContracts).values({
contractNumber,
@@ -141,10 +146,13 @@ export async function transmitToContract(biddingId: number, userId: number) {
currency: biddingData.currency || 'KRW',
// 계약 조건 정보 추가
paymentTerm: biddingCondition?.paymentTerms || null,
+ paymentDelivery: biddingCondition?.paymentTerms || null, // 지급조건 (납품 지급조건)
taxType: biddingCondition?.taxConditions || 'V0',
deliveryTerm: biddingCondition?.incoterms || 'FOB',
shippingLocation: biddingCondition?.shippingPort || null,
dischargeLocation: biddingCondition?.destinationPort || null,
+ contractDeliveryDate: biddingCondition?.contractDeliveryDate || null, // 계약납기일
+ interlockingSystem: interlockingSystem, // 연동제 여부
registeredById: userId,
lastUpdatedById: userId,
}).returning({ id: generalContracts.id })
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts
index 3d07d49c..49b9f847 100644
--- a/lib/bidding/approval-actions.ts
+++ b/lib/bidding/approval-actions.ts
@@ -81,6 +81,7 @@ export async function prepareBiddingApprovalData(data: {
projectName: biddings.projectName,
itemName: biddings.itemName,
biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
bidPicName: biddings.bidPicName,
supplyPicName: biddings.supplyPicName,
submissionStartDate: biddings.submissionStartDate,
@@ -166,6 +167,7 @@ export async function prepareBiddingApprovalData(data: {
...bidding,
projectName: bidding.projectName || undefined,
itemName: bidding.itemName || undefined,
+ awardCount: bidding.awardCount || undefined,
bidPicName: bidding.bidPicName || undefined,
supplyPicName: bidding.supplyPicName || undefined,
targetPrice: bidding.targetPrice ? Number(bidding.targetPrice) : undefined,
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index f52ecb1e..a0aa3378 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -3,7 +3,7 @@
import db from '@/db/db'
import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users, vendorContacts } from '@/db/schema'
import { specificationMeetings, biddingCompaniesContacts } from '@/db/schema/bidding'
-import { eq, and, sql, desc, ne, asc } from 'drizzle-orm'
+import { eq, and, sql, desc, ne, asc, inArray } from 'drizzle-orm'
import { revalidatePath, revalidateTag } from 'next/cache'
import { unstable_cache } from "@/lib/unstable-cache";
import { sendEmail } from '@/lib/mail/sendEmail'
@@ -2596,101 +2596,72 @@ export async function getBiddingDocumentsForPartners(biddingId: number) {
// 입찰가 비교 분석 함수들
// =================================================
-// 벤더별 입찰가 정보 조회 (캐시 적용)
+// 벤더별 입찰가 정보 조회 (최적화 및 간소화됨)
export async function getVendorPricesForBidding(biddingId: number) {
- return unstable_cache(
- async () => {
- try {
- // 각 회사의 입찰가 정보를 조회 - 본입찰 참여 업체들
- const vendorPrices = await db
- .select({
- companyId: biddingCompanies.companyId,
- companyName: vendors.vendorName,
- biddingCompanyId: biddingCompanies.id,
- currency: sql<string>`'KRW'`, // 기본값 KRW
- finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- isBiddingParticipated: biddingCompanies.isBiddingParticipated,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isBiddingParticipated, true), // 본입찰 참여 업체만
- sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL` // 입찰가를 제출한 업체만
- ))
+ try {
+ // 1. 본입찰 참여 업체들 조회
+ const participatingVendors = await db
+ .select({
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ biddingCompanyId: biddingCompanies.id,
+ currency: sql<string>`'KRW'`, // 기본값 KRW
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ isBiddingParticipated: biddingCompanies.isBiddingParticipated,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isBiddingParticipated, true) // 본입찰 참여 업체만
+ ))
- console.log(`Found ${vendorPrices.length} vendors for bidding ${biddingId}`)
+ if (participatingVendors.length === 0) {
+ return []
+ }
- const result: any[] = []
+ const biddingCompanyIds = participatingVendors.map(v => v.biddingCompanyId)
- for (const vendor of vendorPrices) {
- try {
- // 해당 회사의 품목별 입찰가 조회 (본입찰 데이터)
- const itemPrices = await db
- .select({
- prItemId: companyPrItemBids.prItemId,
- itemName: prItemsForBidding.itemInfo, // itemInfo 사용
- itemNumber: prItemsForBidding.itemNumber, // itemNumber도 포함
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- weight: prItemsForBidding.totalWeight, // totalWeight 사용
- weightUnit: prItemsForBidding.weightUnit,
- unitPrice: companyPrItemBids.bidUnitPrice,
- amount: companyPrItemBids.bidAmount,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- })
- .from(companyPrItemBids)
- .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
- .where(and(
- eq(companyPrItemBids.biddingCompanyId, vendor.biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만
- ))
- .orderBy(prItemsForBidding.id)
-
- console.log(`Vendor ${vendor.companyName}: Found ${itemPrices.length} item prices`)
-
- // 총 금액은 biddingCompanies.finalQuoteAmount 사용
- const totalAmount = parseFloat(vendor.finalQuoteAmount || '0')
-
- result.push({
- companyId: vendor.companyId,
- companyName: vendor.companyName || `Vendor ${vendor.companyId}`,
- biddingCompanyId: vendor.biddingCompanyId,
- totalAmount,
- currency: vendor.currency,
- itemPrices: itemPrices.map(item => ({
- prItemId: item.prItemId,
- itemName: item.itemName || item.itemNumber || `Item ${item.prItemId}`,
- quantity: parseFloat(item.quantity || '0'),
- quantityUnit: item.quantityUnit || 'ea',
- weight: item.weight ? parseFloat(item.weight) : null,
- weightUnit: item.weightUnit,
- unitPrice: parseFloat(item.unitPrice || '0'),
- amount: parseFloat(item.amount || '0'),
- proposedDeliveryDate: item.proposedDeliveryDate ?
- (typeof item.proposedDeliveryDate === 'string'
- ? item.proposedDeliveryDate
- : item.proposedDeliveryDate.toISOString().split('T')[0])
- : null,
- }))
- })
- } catch (vendorError) {
- console.error(`Error processing vendor ${vendor.companyId}:`, vendorError)
- // 벤더 처리 중 에러가 발생해도 다른 벤더들은 계속 처리
- }
- }
+ // 2. 해당 업체들의 입찰 품목 조회 (한 번의 쿼리로 최적화)
+ // 필요한 필드만 조회: prItemId, bidUnitPrice, bidAmount
+ const allItemBids = await db
+ .select({
+ biddingCompanyId: companyPrItemBids.biddingCompanyId,
+ prItemId: companyPrItemBids.prItemId,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ })
+ .from(companyPrItemBids)
+ .where(and(
+ inArray(companyPrItemBids.biddingCompanyId, biddingCompanyIds),
+ eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만
+ ))
- return result
- } catch (error) {
- console.error('Failed to get vendor prices for bidding:', error)
- return []
+ // 3. 업체별로 데이터 매핑
+ const result = participatingVendors.map(vendor => {
+ const vendorItems = allItemBids.filter(item => item.biddingCompanyId === vendor.biddingCompanyId)
+
+ const totalAmount = parseFloat(vendor.finalQuoteAmount || '0')
+
+ return {
+ companyId: vendor.companyId,
+ companyName: vendor.companyName || `Vendor ${vendor.companyId}`,
+ biddingCompanyId: vendor.biddingCompanyId,
+ totalAmount,
+ currency: vendor.currency,
+ itemPrices: vendorItems.map(item => ({
+ prItemId: item.prItemId,
+ unitPrice: parseFloat(item.bidUnitPrice || '0'),
+ amount: parseFloat(item.bidAmount || '0'),
+ }))
}
- },
- [`bidding-vendor-prices-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'quotation-vendors', 'pr-items']
- }
- )()
+ })
+
+ return result
+ } catch (error) {
+ console.error('Failed to get vendor prices for bidding:', error)
+ return []
+ }
}
// 사양설명회 참여 여부 업데이트
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index fffac0c1..a6f64964 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -27,6 +27,7 @@ interface BiddingDetailVendorTableContentProps {
onOpenSelectionReasonDialog: () => void
onViewItemDetails?: (vendor: QuotationVendor) => void
onViewQuotationHistory?: (vendor: QuotationVendor) => void
+ readOnly?: boolean
}
const filterFields: DataTableFilterField<QuotationVendor>[] = [
@@ -86,7 +87,8 @@ export function BiddingDetailVendorTableContent({
vendors,
onRefresh,
onViewItemDetails,
- onViewQuotationHistory
+ onViewQuotationHistory,
+ readOnly = false
}: BiddingDetailVendorTableContentProps) {
const { data: session } = useSession()
const { toast } = useToast()
@@ -269,6 +271,7 @@ export function BiddingDetailVendorTableContent({
onSuccess={onRefresh}
winnerVendor={vendors.find(v => v.awardRatio === 100)}
singleSelectedVendor={singleSelectedVendor}
+ readOnly={readOnly}
/>
</DataTableAdvancedToolbar>
</DataTable>
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
index 8df29289..7e571a23 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -25,6 +25,7 @@ interface BiddingDetailVendorToolbarActionsProps {
onSuccess: () => void
winnerVendor?: QuotationVendor | null // 100% 낙찰된 벤더
singleSelectedVendor?: QuotationVendor | null // single select된 벤더
+ readOnly?: boolean
}
export function BiddingDetailVendorToolbarActions({
@@ -35,7 +36,8 @@ export function BiddingDetailVendorToolbarActions({
onOpenAwardRatioDialog,
onSuccess,
winnerVendor,
- singleSelectedVendor
+ singleSelectedVendor,
+ readOnly = false
}: BiddingDetailVendorToolbarActionsProps) {
const router = useRouter()
const { toast } = useToast()
@@ -82,53 +84,6 @@ export function BiddingDetailVendorToolbarActions({
setIsBiddingInvitationDialogOpen(true)
}
- // const handleBiddingInvitationSend = async (data: any) => {
- // try {
- // // 1. 기본계약 발송
- // const contractResult = await sendBiddingBasicContracts(
- // biddingId,
- // data.vendors,
- // data.generatedPdfs,
- // data.message
- // )
-
- // if (!contractResult.success) {
- // toast({
- // title: '기본계약 발송 실패',
- // description: contractResult.error,
- // variant: 'destructive',
- // })
- // return
- // }
-
- // // 2. 입찰 등록 진행
- // const registerResult = await registerBidding(bidding.id, userId)
-
- // if (registerResult.success) {
- // toast({
- // title: '본입찰 초대 완료',
- // description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.',
- // })
- // setIsBiddingInvitationDialogOpen(false)
- // router.refresh()
- // onSuccess()
- // } else {
- // toast({
- // title: '오류',
- // description: registerResult.error,
- // variant: 'destructive',
- // })
- // }
- // } catch (error) {
- // console.error('본입찰 초대 실패:', error)
- // toast({
- // title: '오류',
- // description: '본입찰 초대에 실패했습니다.',
- // variant: 'destructive',
- // })
- // }
- // }
-
// 선정된 업체들 조회 (서버 액션 함수 사용)
const getSelectedVendors = async () => {
try {
@@ -165,27 +120,6 @@ export function BiddingDetailVendorToolbarActions({
})
}
- const handleRoundIncrease = () => {
- startTransition(async () => {
- const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase')
-
- if (result.success) {
- toast({
- title: "성공",
- description: result.message,
- })
- router.push(`/evcp/bid`)
- onSuccess()
- } else {
- toast({
- title: "오류",
- description: result.error || "차수증가 중 오류가 발생했습니다.",
- variant: 'destructive',
- })
- }
- })
- }
-
const handleCancelAward = () => {
if (!winnerVendor) return
@@ -233,69 +167,74 @@ export function BiddingDetailVendorToolbarActions({
return (
<>
<div className="flex items-center gap-2">
- {/* 상태별 액션 버튼 */}
- {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsRoundIncreaseDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCw className="mr-2 h-4 w-4" />
- 차수증가
- </Button>
- )}
-
- {/* 발주비율 산정: single select 시에만 활성화 */}
- {(bidding.status === 'evaluation_of_bidding') && (
- <Button
- variant="outline"
- size="sm"
- onClick={onOpenAwardRatioDialog}
- disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
- >
- <DollarSign className="mr-2 h-4 w-4" />
- 발주비율 산정
- </Button>
- )}
-
- {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ {/* 상태별 액션 버튼 - 읽기 전용이 아닐 때만 표시 */}
+ {!readOnly && (
<>
- <Button
- variant="destructive"
- size="sm"
- onClick={handleMarkAsDisposal}
- disabled={isPending}
- >
- <XCircle className="mr-2 h-4 w-4" />
- 유찰
- </Button>
- <Button
- variant="default"
- size="sm"
- onClick={onOpenAwardDialog}
- disabled={isPending}
- >
- <Trophy className="mr-2 h-4 w-4" />
- 낙찰
- </Button>
+ {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */}
+ {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsRoundIncreaseDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCw className="mr-2 h-4 w-4" />
+ 차수증가
+ </Button>
+ )}
+
+ {/* 발주비율 산정: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenAwardRatioDialog}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+ <DollarSign className="mr-2 h-4 w-4" />
+ 발주비율 산정
+ </Button>
+ )}
+
+ {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
+ {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ <>
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={handleMarkAsDisposal}
+ disabled={isPending}
+ >
+ <XCircle className="mr-2 h-4 w-4" />
+ 유찰
+ </Button>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={onOpenAwardDialog}
+ disabled={isPending}
+ >
+ <Trophy className="mr-2 h-4 w-4" />
+ 낙찰
+ </Button>
+ </>
+ )}
+
+ {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
+ {winnerVendor && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsCancelAwardDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCcw className="mr-2 h-4 w-4" />
+ 발주비율 취소
+ </Button>
+ )}
</>
)}
- {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
- {winnerVendor && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsCancelAwardDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCcw className="mr-2 h-4 w-4" />
- 발주비율 취소
- </Button>
- )}
{/* 구분선 */}
{(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 (
<>
<div className="flex items-center gap-2">
@@ -100,6 +117,17 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
// 성공 시 테이블 새로고침 등 추가 작업
// window.location.reload()
}} />
+ {/* Excel 내보내기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ disabled={isExporting}
+ className="gap-2"
+ >
+ <FileSpreadsheet className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">{isExporting ? "내보내는 중..." : "Excel 내보내기"}</span>
+ </Button>
{/* 전송하기 (업체선정 완료된 입찰만) */}
<Button
variant="default"
@@ -112,20 +140,16 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
<span className="hidden sm:inline">전송하기</span>
</Button>
{/* 삭제 버튼 */}
-
- <Button
- variant="destructive"
- size="sm"
- onClick={() => setIsDeleteDialogOpen(true)}
- disabled={!canDelete}
- className="gap-2"
- >
- <Trash className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">삭제</span>
- </Button>
-
-
-
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={() => setIsDeleteDialogOpen(true)}
+ disabled={!canDelete}
+ className="gap-2"
+ >
+ <Trash className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">삭제</span>
+ </Button>
</div>
{/* 전송 다이얼로그 */}
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<BiddingListItemWithManagerCode>,
+ {
+ filename = "입찰목록",
+ onlySelected = false,
+ }: {
+ filename?: string
+ onlySelected?: boolean
+ } = {}
+): Promise<void> {
+ // 테이블에서 실제 사용 중인 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<void> {
+ // 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<void> {
+ // 프로젝트 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<void> {
+ // 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<ImportBiddingItemsResult> {
+ 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<string, number> = {}
+ 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<string | null> {
+ 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<number | null> {
+ 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<Map<number, string>> {
+ 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<number, string>()
+ 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) {
입찰유형
</label>
<div className="text-sm font-medium">
- {bidding.isPublic ? '공개입찰' : '비공개입찰'}
+ {bidding.biddingType}
</div>
</div>
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 (
+ <Card>
+ <CardHeader>
+ <CardTitle>응찰품목</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <div className="text-sm text-muted-foreground">로딩 중...</div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
+ 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 (
+ <Card>
+ <CardHeader>
+ <CardTitle>응찰품목</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <ScrollArea className="w-full whitespace-nowrap rounded-md border">
+ <div className="w-max min-w-full">
+ <table className="w-full caption-bottom text-sm">
+ <thead className="[&_tr]:border-b">
+ {/* Header Row 1: Base Info + Vendor Groups */}
+ <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재번호</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역상세</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>구매단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>수량</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>총중량</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>중량단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정단가</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정액</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>통화</th>
+
+ {vendorPrices.map((vendor) => (
+ <th key={vendor.companyId} colSpan={4} className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r bg-muted/20">
+ {vendor.companyName}
+ </th>
+ ))}
+ </tr>
+ {/* Header Row 2: Vendor Sub-columns */}
+ <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
+ {vendorPrices.map((vendor) => (
+ <React.Fragment key={vendor.companyId}>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">단가</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">총액</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">통화</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">내정액(%)</th>
+ </React.Fragment>
+ ))}
+ </tr>
+ </thead>
+ <tbody className="[&_tr:last-child]:border-0">
+ {/* Summary Row */}
+ <tr className="border-b transition-colors hover:bg-muted/50 bg-muted/30 font-semibold">
+ <td className="p-4 align-middle text-center border-r" colSpan={4}>합계</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totalQuantity)}</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totalWeight)}</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totalTargetAmount)}</td>
+ <td className="p-4 align-middle text-center border-r">KRW</td>
+
+ {vendorPrices.map((vendor) => {
+ const vTotal = vendorTotals.find(t => t.companyId === vendor.companyId)?.totalAmount || 0
+ const ratio = totalTargetAmount > 0 ? (vTotal / totalTargetAmount) * 100 : 0
+ return (
+ <React.Fragment key={vendor.companyId}>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(vTotal)}</td>
+ <td className="p-4 align-middle text-center border-r">{vendor.currency}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(ratio, 0)}%</td>
+ </React.Fragment>
+ )
+ })}
+ </tr>
+
+ {/* Data Rows */}
+ {prItems.map((item) => (
+ <tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
+ <td className="p-4 align-middle border-r">{item.materialNumber}</td>
+ <td className="p-4 align-middle border-r min-w-[150px]">{item.materialInfo}</td>
+ <td className="p-4 align-middle border-r min-w-[150px]">{item.specification}</td>
+ <td className="p-4 align-middle text-center border-r">{item.purchaseUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.quantity)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.quantityUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.totalWeight)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.weightUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetUnitPrice)}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetAmount)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.currency}</td>
+
+ {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 (
+ <React.Fragment key={vendor.companyId}>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem ? formatNumber(bidItem.unitPrice) : '-'}
+ </td>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem ? formatNumber(bidItem.amount) : '-'}
+ </td>
+ <td className="p-4 align-middle text-center border-r bg-muted/5">
+ {vendor.currency}
+ </td>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem && ratio > 0 ? `${formatNumber(ratio, 0)}%` : '-'}
+ </td>
+ </React.Fragment>
+ )
+ })}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ <ScrollBar orientation="horizontal" />
+ </ScrollArea>
+ </CardContent>
+ </Card>
+ )
+}
+
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({
<BiddingInfoCard bidding={bidding} />
{/* 선정결과 폼 */}
- <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} />
+ <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} readOnly={isReadOnly} />
{/* 업체선정 테이블 */}
<VendorSelectionTable
@@ -35,7 +39,12 @@ export function BiddingSelectionDetailContent({
biddingId={biddingId}
bidding={bidding}
onRefresh={handleRefresh}
+ readOnly={isReadOnly}
/>
+
+ {/* 응찰품목 테이블 */}
+ <BiddingItemTable biddingId={biddingId} />
+
</div>
)
}
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<typeof selectionResultSchema>
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<File[]>([])
+ const [existingAttachments, setExistingAttachments] = React.useState<AttachmentInfo[]>([])
const form = useForm<SelectionResultFormData>({
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 (
+ <Card>
+ <CardHeader>
+ <CardTitle>선정결과</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">로딩 중...</span>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
return (
<Card>
<CardHeader>
@@ -94,6 +166,7 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
placeholder="선정결과에 대한 요약을 입력해주세요..."
className="min-h-[120px]"
{...field}
+ disabled={readOnly}
/>
</FormControl>
<FormMessage />
@@ -104,35 +177,83 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
{/* 첨부파일 */}
<div className="space-y-4">
<FormLabel>첨부파일</FormLabel>
- <Dropzone
- maxSize={10 * 1024 * 1024} // 10MB
- onDropAccepted={(files) => {
- const newFiles = Array.from(files)
- setAttachmentFiles(prev => [...prev, ...newFiles])
- }}
- onDropRejected={() => {
- toast({
- title: "파일 업로드 거부",
- description: "파일 크기 및 형식을 확인해주세요.",
- variant: "destructive",
- })
- }}
- >
- <DropzoneZone>
- <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
- <DropzoneTitle className="text-lg font-medium">
- 파일을 드래그하거나 클릭하여 업로드
- </DropzoneTitle>
- <DropzoneDescription className="text-sm text-muted-foreground">
- PDF, Word, Excel, 이미지 파일 (최대 10MB)
- </DropzoneDescription>
- </DropzoneZone>
- <DropzoneInput />
- </Dropzone>
+
+ {/* 기존 첨부파일 */}
+ {existingAttachments.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">기존 첨부파일</h4>
+ <div className="space-y-2">
+ {existingAttachments.map((attachment) => (
+ <div
+ key={attachment.id}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{attachment.originalFileName || attachment.fileName}</p>
+ <p className="text-xs text-muted-foreground">
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => downloadAttachment(attachment.filePath, attachment.originalFileName || attachment.fileName)}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ {!readOnly && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeExistingAttachment(attachment.id)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {!readOnly && (
+ <Dropzone
+ maxSize={10 * 1024 * 1024} // 10MB
+ onDropAccepted={(files) => {
+ const newFiles = Array.from(files)
+ setAttachmentFiles(prev => [...prev, ...newFiles])
+ }}
+ onDropRejected={() => {
+ toast({
+ title: "파일 업로드 거부",
+ description: "파일 크기 및 형식을 확인해주세요.",
+ variant: "destructive",
+ })
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
+ <DropzoneTitle className="text-lg font-medium">
+ 파일을 드래그하거나 클릭하여 업로드
+ </DropzoneTitle>
+ <DropzoneDescription className="text-sm text-muted-foreground">
+ PDF, Word, Excel, 이미지 파일 (최대 10MB)
+ </DropzoneDescription>
+ </DropzoneZone>
+ <DropzoneInput />
+ </Dropzone>
+ )}
{attachmentFiles.length > 0 && (
<div className="space-y-2">
- <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <h4 className="text-sm font-medium">새로 추가할 파일</h4>
<div className="space-y-2">
{attachmentFiles.map((file, index) => (
<div
@@ -148,14 +269,16 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
</p>
</div>
</div>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeAttachmentFile(index)}
- >
- 제거
- </Button>
+ {!readOnly && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeAttachmentFile(index)}
+ >
+ 제거
+ </Button>
+ )}
</div>
))}
</div>
@@ -164,13 +287,15 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
</div>
{/* 저장 버튼 */}
- <div className="flex justify-end">
- <Button type="submit" disabled={isSubmitting}>
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- <Save className="mr-2 h-4 w-4" />
- 저장
- </Button>
- </div>
+ {!readOnly && (
+ <div className="flex justify-end">
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ <Save className="mr-2 h-4 w-4" />
+ 저장
+ </Button>
+ </div>
+ )}
</form>
</Form>
</CardContent>
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<any[]>([])
const [loading, setLoading] = React.useState(true)
@@ -59,6 +60,7 @@ export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSe
vendors={vendors}
onRefresh={onRefresh}
onOpenSelectionReasonDialog={() => {}}
+ readOnly={readOnly}
/>
</CardContent>
</Card>
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({
</span>
) : (
<Input
- type="number"
- inputMode="decimal"
- min={0}
- pattern="^(0|[1-9][0-9]*)(\.[0-9]+)?$"
- value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice}
+ type="text"
+ inputMode="numeric"
+ value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice.toLocaleString()}
onChange={(e) => {
- 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<PartnersBiddingListItem>,
+ {
+ filename = "협력업체입찰목록",
+ onlySelected = false,
+ }: {
+ filename?: string
+ onlySelected?: boolean
+ } = {}
+): Promise<void> {
+ // 테이블에서 실제 사용 중인 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<string, string> = {
+ 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<void> {
+ // 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 <div className="text-muted-foreground text-center">-</div>
+ return <div className="text-muted-foreground text-center">해당없음</div>
}
return isAttending ? (
<CheckCircle className="h-5 w-5 text-green-600 mx-auto" />
@@ -366,31 +366,31 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
}),
// 사전견적 마감일
- columnHelper.accessor('preQuoteDeadline', {
- header: '사전견적 마감일',
- cell: ({ row }) => {
- const deadline = row.original.preQuoteDeadline
- if (!deadline) {
- return <div className="text-muted-foreground">-</div>
- }
+ // columnHelper.accessor('preQuoteDeadline', {
+ // header: '사전견적 마감일',
+ // cell: ({ row }) => {
+ // const deadline = row.original.preQuoteDeadline
+ // if (!deadline) {
+ // return <div className="text-muted-foreground">-</div>
+ // }
- 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 (
- <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}>
- <Calendar className="w-4 h-4" />
- <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span>
- {isExpired && (
- <Badge variant="destructive" className="text-xs">
- 마감
- </Badge>
- )}
- </div>
- )
- },
- }),
+ // return (
+ // <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}>
+ // <Calendar className="w-4 h-4" />
+ // <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span>
+ // {isExpired && (
+ // <Badge variant="destructive" className="text-xs">
+ // 마감
+ // </Badge>
+ // )}
+ // </div>
+ // )
+ // },
+ // }),
// 계약기간
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<PartnersBiddingListItem>
@@ -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 (
<div className="flex items-center gap-2">
+ {/* Excel 내보내기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ disabled={isExporting}
+ className="gap-2"
+ >
+ <FileSpreadsheet className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">{isExporting ? "내보내는 중..." : "Excel 내보내기"}</span>
+ </Button>
<Button
variant="outline"
size="sm"
diff --git a/lib/general-contracts/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx
index b0378912..d7533d2e 100644
--- a/lib/general-contracts/detail/general-contract-basic-info.tsx
+++ b/lib/general-contracts/detail/general-contract-basic-info.tsx
@@ -8,7 +8,21 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
-import { Save, LoaderIcon } from 'lucide-react'
+import { Save, LoaderIcon, Check, ChevronsUpDown } from 'lucide-react'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import { cn } from '@/lib/utils'
import { updateContractBasicInfo, getContractBasicInfo } from '../service'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -140,19 +154,28 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
// paymentDelivery에서 퍼센트와 타입 분리
const paymentDeliveryValue = contractData?.paymentDelivery || ''
+ console.log(paymentDeliveryValue,"paymentDeliveryValue")
let paymentDeliveryType = ''
let paymentDeliveryPercentValue = ''
- if (paymentDeliveryValue.includes('%')) {
+ // "60일 이내" 또는 "추가조건"은 그대로 사용
+ if (paymentDeliveryValue === '납품완료일로부터 60일 이내 지급' || paymentDeliveryValue === '추가조건') {
+ paymentDeliveryType = paymentDeliveryValue
+ } else if (paymentDeliveryValue.includes('%')) {
+ // 퍼센트가 포함된 경우 (예: "10% L/C")
const match = paymentDeliveryValue.match(/(\d+)%\s*(.+)/)
if (match) {
paymentDeliveryPercentValue = match[1]
paymentDeliveryType = match[2]
+ } else {
+ paymentDeliveryType = paymentDeliveryValue
}
} else {
+ // 일반 지급조건 코드 (예: "P008")
paymentDeliveryType = paymentDeliveryValue
}
-
+ console.log(paymentDeliveryType,"paymentDeliveryType")
+ console.log(paymentDeliveryPercentValue,"paymentDeliveryPercentValue")
setPaymentDeliveryPercent(paymentDeliveryPercentValue)
// 합의계약(AD, AW)인 경우 인도조건 기본값 설정
@@ -309,6 +332,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
loadShippingPlaces();
loadDestinationPlaces();
}, [loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]);
+
const handleSaveContractInfo = async () => {
if (!userId) {
toast.error('사용자 정보를 찾을 수 없습니다.')
@@ -342,12 +366,29 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
return
}
- // paymentDelivery와 paymentDeliveryPercent 합쳐서 저장
+ // paymentDelivery 저장 로직
+ // 1. "60일 이내" 또는 "추가조건"은 그대로 저장
+ // 2. L/C 또는 T/T이고 퍼센트가 있으면 "퍼센트% 코드" 형식으로 저장
+ // 3. 그 외의 경우는 그대로 저장
+ let paymentDeliveryToSave = formData.paymentDelivery
+
+ if (
+ formData.paymentDelivery !== '납품완료일로부터 60일 이내 지급' &&
+ formData.paymentDelivery !== '추가조건' &&
+ (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') &&
+ paymentDeliveryPercent
+ ) {
+ paymentDeliveryToSave = `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
+ }
+ console.log(paymentDeliveryToSave,"paymentDeliveryToSave")
+
const dataToSave = {
...formData,
- paymentDelivery: (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && paymentDeliveryPercent
- ? `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
- : formData.paymentDelivery
+ paymentDelivery: paymentDeliveryToSave,
+ // 추가조건 선택 시에만 추가 텍스트 저장, 그 외에는 빈 문자열 또는 undefined
+ paymentDeliveryAdditionalText: formData.paymentDelivery === '추가조건'
+ ? (formData.paymentDeliveryAdditionalText || '')
+ : ''
}
await updateContractBasicInfo(contractId, dataToSave, userId as number)
@@ -1026,20 +1067,100 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="paymentDelivery" className="text-xs">지급조건 *</Label>
- <Select value={formData.paymentDelivery} onValueChange={(value) => setFormData(prev => ({ ...prev, paymentDelivery: value }))}>
- <SelectTrigger className={`h-8 text-xs ${errors.paymentDelivery ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.map((term) => (
- <SelectItem key={term.code} value={term.code} className="text-xs">
- {term.code}
- </SelectItem>
- ))}
- <SelectItem value="납품완료일로부터 60일 이내 지급" className="text-xs">60일 이내</SelectItem>
- <SelectItem value="추가조건" className="text-xs">추가조건</SelectItem>
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.paymentDelivery && "text-muted-foreground",
+ errors.paymentDelivery && "border-red-500"
+ )}
+ >
+ {formData.paymentDelivery
+ ? (() => {
+ // 1. paymentTermsOptions에서 찾기
+ const foundOption = paymentTermsOptions.find((option) => option.code === formData.paymentDelivery)
+ if (foundOption) {
+ return `${foundOption.code} ${foundOption.description ? `(${foundOption.description})` : ''}`
+ }
+ // 2. 특수 케이스 처리
+ if (formData.paymentDelivery === '납품완료일로부터 60일 이내 지급') {
+ return '60일 이내'
+ }
+ if (formData.paymentDelivery === '추가조건') {
+ return '추가조건'
+ }
+ // 3. 그 외의 경우 원본 값 표시 (로드된 값이지만 옵션에 없는 경우)
+ return formData.paymentDelivery
+ })()
+ : "지급조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="지급조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {paymentTermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.paymentDelivery
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))}
+ <CommandItem
+ value="납품완료일로부터 60일 이내 지급"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '납품완료일로부터 60일 이내 지급' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '납품완료일로부터 60일 이내 지급'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 60일 이내
+ </CommandItem>
+ <CommandItem
+ value="추가조건"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '추가조건' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '추가조건'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 추가조건
+ </CommandItem>
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
{formData.paymentDelivery === '추가조건' && (
<Input
type="text"
@@ -1152,53 +1273,59 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
</div>
</div>
- {/* 지불조건 -> 세금조건 (지불조건 삭제됨) */}
+ {/*세금조건*/}
<div className="space-y-2">
<Label className="text-sm font-medium">세금조건</Label>
<div className="space-y-2">
- {/* 지불조건 필드 삭제됨
- <div className="space-y-1">
- <Label htmlFor="paymentTerm" className="text-xs">지불조건 *</Label>
- <Select
- value={formData.paymentTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, paymentTerm: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.paymentTerm ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- */}
<div className="space-y-1">
<Label htmlFor="taxType" className="text-xs">세금조건 *</Label>
- <Select
- value={formData.taxType}
- onValueChange={(value) => setFormData(prev => ({ ...prev, taxType: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.taxType ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code} className="text-xs">
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.taxType && "text-muted-foreground",
+ errors.taxType && "border-red-500"
+ )}
+ >
+ {formData.taxType
+ ? TAX_CONDITIONS.find((condition) => condition.code === formData.taxType)?.name
+ : "세금조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="세금조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {TAX_CONDITIONS.map((condition) => (
+ <CommandItem
+ key={condition.code}
+ value={`${condition.code} ${condition.name}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, taxType: condition.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ condition.code === formData.taxType
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {condition.name}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
</div>
</div>
@@ -1266,79 +1393,178 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
{/* 인도조건 */}
<div className="space-y-2">
<Label htmlFor="deliveryTerm" className="text-xs">인도조건</Label>
- <Select
- value={formData.deliveryTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, deliveryTerm: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.deliveryTerm && "text-muted-foreground"
+ )}
+ >
+ {formData.deliveryTerm
+ ? incotermsOptions.find((option) => option.code === formData.deliveryTerm)
+ ? `${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.code} ${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description ? `(${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description})` : ''}`
+ : formData.deliveryTerm
+ : "인코텀즈 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="인코텀즈 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, deliveryTerm: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.deliveryTerm
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 선적지 */}
<div className="space-y-2">
<Label htmlFor="shippingLocation" className="text-xs">선적지</Label>
- <Select
- value={formData.shippingLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, shippingLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.shippingLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.shippingLocation
+ ? shippingPlaces.find((place) => place.code === formData.shippingLocation)
+ ? `${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.code} ${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description ? `(${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description})` : ''}`
+ : formData.shippingLocation
+ : "선적지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="선적지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, shippingLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.shippingLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 하역지 */}
<div className="space-y-2">
<Label htmlFor="dischargeLocation" className="text-xs">하역지</Label>
- <Select
- value={formData.dischargeLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, dischargeLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.dischargeLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.dischargeLocation
+ ? destinationPlaces.find((place) => place.code === formData.dischargeLocation)
+ ? `${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.code} ${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description ? `(${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description})` : ''}`
+ : formData.dischargeLocation
+ : "하역지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="하역지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, dischargeLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.dischargeLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 계약납기일 */}
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({
<Plus className="w-4 h-4" />
행 추가
</Button>
+ <ProcurementItemSelectorDialogSingle
+ triggerLabel="1회성 품목 추가"
+ triggerVariant="outline"
+ triggerSize="sm"
+ selectedProcurementItem={null}
+ onProcurementItemSelect={handleOneTimeItemSelect}
+ title="1회성 품목 선택"
+ description="추가할 1회성 품목을 선택해주세요."
+ showConfirmButtons={false}
+ disabled={!isEnabled || readOnly}
+ />
<Dialog open={showBatchInputDialog} onOpenChange={setShowBatchInputDialog}>
<DialogTrigger asChild>
<Button
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts
index 3f3dc8de..72b6449f 100644
--- a/lib/general-contracts/service.ts
+++ b/lib/general-contracts/service.ts
@@ -504,7 +504,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
linkedBidNumber,
notes,
paymentBeforeDelivery, // JSON 필드
- paymentDelivery: convertToNumberOrNull(paymentDelivery),
+ paymentDelivery,
paymentAfterDelivery, // JSON 필드
paymentTerm,
taxType,
@@ -525,7 +525,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
lastUpdatedAt: new Date(),
lastUpdatedById: userId,
}
-
+ console.log(updateData.paymentDelivery,"updateData.paymentDelivery")
// DB에 업데이트 실행
const [updatedContract] = await db
.update(generalContracts)
@@ -533,14 +533,9 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
.where(eq(generalContracts.id, id))
.returning()
- // 계약명 I/F 로직 (39번 화면으로의 I/F)
- // TODO: 39번 화면의 정확한 API 엔드포인트나 함수명 확인 필요
- // if (data.name) {
- // await syncContractNameToScreen39(id, data.name as string)
- // }
revalidatePath('/general-contracts')
- revalidatePath(`/general-contracts/detail/${id}`)
+ revalidatePath(`/general-contracts/${id}`)
return updatedContract
} catch (error) {
console.error('Error updating contract basic info:', error)
diff --git a/lib/procurement-items/service.ts b/lib/procurement-items/service.ts
index b62eb8df..c91959a9 100644
--- a/lib/procurement-items/service.ts
+++ b/lib/procurement-items/service.ts
@@ -255,8 +255,19 @@ export async function searchProcurementItems(query: string): Promise<{ itemCode:
unstable_noStore()
try {
+ // 검색어가 없으면 상위 50개 반환
if (!query || query.trim().length < 1) {
- return []
+ const results = await db
+ .select({
+ itemCode: procurementItems.itemCode,
+ itemName: procurementItems.itemName,
+ })
+ .from(procurementItems)
+ .where(eq(procurementItems.isActive, 'Y'))
+ .limit(50)
+ .orderBy(asc(procurementItems.itemCode))
+
+ return results
}
const searchQuery = `%${query.trim()}%`
@@ -277,7 +288,7 @@ export async function searchProcurementItems(query: string): Promise<{ itemCode:
eq(procurementItems.isActive, 'Y')
)
)
- .limit(20)
+ .limit(50)
.orderBy(asc(procurementItems.itemCode))
return results
diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
index 9bdd238d..00873d83 100644
--- a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
+++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
@@ -32,6 +32,7 @@ import {
parseSAPDateToString,
findSpecificationByMATNR,
} from './common-mapper-utils';
+import { updateBiddingAmounts } from '@/lib/bidding/service';
// Note: POS 파일은 온디맨드 방식으로 다운로드됩니다.
// 자동 동기화 관련 import는 제거되었습니다.
@@ -497,6 +498,20 @@ export async function mapAndSaveECCBiddingData(
};
});
+ // 새로 생성된 Bidding들에 대해 금액 집계 업데이트 (PR 아이템의 금액 정보를 Bidding 헤더에 반영)
+ if (result.insertedBiddings && result.insertedBiddings.length > 0) {
+ debugLog('Bidding 금액 집계 업데이트 시작', { count: result.insertedBiddings.length });
+ await Promise.all(
+ result.insertedBiddings.map(async (bidding) => {
+ try {
+ await updateBiddingAmounts(bidding.id);
+ } catch (err) {
+ debugError(`Bidding ${bidding.biddingNumber} 금액 업데이트 실패`, err);
+ }
+ })
+ );
+ }
+
debugSuccess('ECC Bidding 데이터 일괄 처리 완료', {
processedCount: result.processedCount,
});