diff options
Diffstat (limited to 'lib')
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, }); |
