diff options
Diffstat (limited to 'lib')
94 files changed, 15707 insertions, 3722 deletions
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts index beb6b971..235c9b7b 100644 --- a/lib/approval/handlers-registry.ts +++ b/lib/approval/handlers-registry.ts @@ -40,9 +40,10 @@ export async function initializeApprovalHandlers() { // 벤더 가입 승인 핸들러 등록 (결재 승인 후 실행될 함수 approveVendorWithMDGInternal) registerActionHandler('vendor_approval', approveVendorWithMDGInternal); - // 5. 계약 승인 핸들러 - // const { approveContractInternal } = await import('@/lib/contract/handlers'); - // registerActionHandler('contract_approval', approveContractInternal); + // 5. 일반계약 승인 핸들러 + const { approveContractInternal } = await import('@/lib/general-contracts/handlers'); + // 일반계약 승인 핸들러 등록 (결재 승인 후 실행될 함수 approveContractInternal) + registerActionHandler('general_contract_approval', approveContractInternal); // 6. RFQ 발송 핸들러 (첨부파일이 있는 경우) const { sendRfqWithApprovalInternal } = await import('@/lib/rfq-last/approval-handlers'); diff --git a/lib/approval/templates/일반계약 결재.html b/lib/approval/templates/일반계약 결재.html new file mode 100644 index 00000000..99389030 --- /dev/null +++ b/lib/approval/templates/일반계약 결재.html @@ -0,0 +1,3024 @@ +<div + + style=" + + max-width: 1000px; + + margin: 0 auto; + + font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; + + font-size: 14px; + + color: #333; + + line-height: 1.5; + + border: 1px solid #666; /* 전체적인 테두리 추가 */ + + " + +> + + <!-- 1. 제목 및 안내 문구 --> + + <table + + style=" + + width: 100%; + + border-collapse: collapse; + + margin-bottom: 0px; + + border-bottom: 2px solid #000; + + " + + > + + <thead> + + <tr> + + <th + + style=" + + background-color: #fff; + + color: #000; + + padding: 15px; + + text-align: center; + + font-size: 20px; + + font-weight: 700; + + " + + > + + 계약 체결 진행 품의 요청서 (구매성) + + </th> + + </tr> + + <tr> + + <td + + style=" + + padding: 5px 15px; + + text-align: right; + + font-size: 12px; + + color: #666; + + border-bottom: 1px solid #ccc; + + " + + > + + *결재 완료 후 계약 체결을 진행할 수 있습니다. + + <br /> + + * 본 계약은 계약 갱신이 불필요하여 만료 알림이 설정되지 않았습니다. + + </td> + + </tr> + + </thead> + + </table> + + + + <!-- 2. 계약 기본 정보 --> + + <table + + style=" + + width: 100%; + + border-collapse: collapse; + + margin-bottom: 15px; + + " + + > + + <thead> + + <tr> + + <th + + colspan="6" + + style=" + + background-color: #333; + + color: #fff; + + padding: 10px; + + text-align: left; + + font-size: 15px; + + font-weight: 600; + + border-bottom: 1px solid #666; + + " + + > + + ■ 계약 기본 정보 + + </th> + + </tr> + + </thead> + + <tbody> + + <!-- 1행 --> + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + width: 15%; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 계약번호 + + </td> + + <td + + style=" + + padding: 8px 10px; + + width: 20%; + + border: 1px solid #ccc; + + " + + > + + {{계약번호}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + width: 15%; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 계약명 + + </td> + + <td + + style=" + + padding: 8px 10px; + + width: 20%; + + border: 1px solid #ccc; + + " + + > + + {{계약명}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + width: 15%; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 계약체결방식 + + </td> + + <td + + style=" + + padding: 8px 10px; + + width: 15%; + + border: 1px solid #ccc; + + " + + > + + {{계약체결방식}} + + </td> + + </tr> + + <!-- 2행 --> + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 계약종류 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{계약종류}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 구매담당자 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{구매담당자}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 업체선정방식 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{업체선정방식}} + + </td> + + </tr> + + <!-- 3행 --> + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 입찰번호 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{입찰번호}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 입찰명 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{입찰명}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 계약기간 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{계약기간}} + + </td> + + </tr> + + <!-- 4행 --> + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 계약일자 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{계약일자}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 매입 부가가치세 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{매입_부가가치세}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 계약 담당자 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{계약_담당자}} + + </td> + + </tr> + + <!-- 5행 --> + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 계약부서 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{계약부서}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 계약 금액 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{계약금액}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + SHI 지급조건 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{SHI_지급조건}} + + </td> + + </tr> + + <!-- 6행 --> + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + SHI 인도조건 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{SHI_인도조건}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + SHI 인도조건(옵션) + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{SHI_인도조건_옵션}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 선적지 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{선적지}} + + </td> + + </tr> + + <!-- 7행 --> + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 하역지 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{하역지}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 사외업체 야드 투입 여부 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{사외업체_야드_투입여부}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 프로젝트 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{프로젝트}} + + </td> + + </tr> + + <!-- 8행 --> + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 직종 + + </td> + + <td + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{직종}} + + </td> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 재하도 협력사 + + </td> + + <td + + colspan="3" + + style=" + + padding: 8px 10px; + + border: 1px solid #ccc; + + " + + > + + {{재하도_협력사}} + + </td> + + </tr> + + <!-- 9행: 계약 내용 --> + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + font-weight: 600; + + border: 1px solid #ccc; + + text-align: center; + + " + + > + + 계약 내용 + + </td> + + <td + + colspan="5" + + style=" + + padding: 8px 10px; + + height: 80px; + + border: 1px solid #ccc; + + vertical-align: top; + + " + + > + + {{계약내용}} + + </td> + + </tr> + + </tbody> + + </table> + + + + <!-- 3. 계약 협력사 및 담당자 정보 --> + + <table + + style=" + + width: 100%; + + border-collapse: collapse; + + margin-bottom: 15px; + + " + + > + + <thead> + + <tr> + + <th + + colspan="6" + + style=" + + background-color: #333; + + color: #fff; + + padding: 10px; + + text-align: left; + + font-size: 15px; + + font-weight: 600; + + border-bottom: 1px solid #666; + + " + + > + + ■ 계약 협력사 및 담당자 정보 + + </th> + + </tr> + + <tr> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 8px 10px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 15%; + + " + + > + + 협력사 코드 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 8px 10px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 25%; + + " + + > + + 협력사명 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 8px 10px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 15%; + + " + + > + + 담당자 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 8px 10px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 15%; + + " + + > + + 전화번호 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 8px 10px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 20%; + + " + + > + + 이메일 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 8px 10px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 10%; + + " + + > + + 비고 + + </th> + + </tr> + + </thead> + + <tbody> + + <!-- 데이터 행 (반복 영역) --> + + <tr> + + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사코드}}</td> + + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명}}</td> + + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_담당자}}</td> + + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{전화번호}}</td> + + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{이메일}}</td> + + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{비고}}</td> + + </tr> + + <!-- /데이터 행 --> + + </tbody> + + </table> + + + + <!-- 4. 계약 대상 자재 정보 --> + + <table + + style=" + + width: 100%; + + border-collapse: collapse; + + margin-bottom: 15px; + + " + + > + + <thead> + + <tr> + + <th + + colspan="15" + + style=" + + background-color: #333; + + color: #fff; + + padding: 10px; + + text-align: left; + + font-size: 15px; + + font-weight: 600; + + border-bottom: 1px solid #666; + + " + + > + + ■ 계약 대상 자재 정보 (총 {{대상_자재_수}}건 - 결재본문 내 표시 자재는 100건 이하로 제한되어 있습니다) + + </th> + + </tr> + + <tr style="font-size: 12px;"> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 5%; + + " + + > + + 순번 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 6%; + + " + + > + + 플랜트 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 6%; + + " + + > + + 프로젝트 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 8%; + + " + + > + + 자재그룹 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 10%; + + " + + > + + 자재그룹명 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 10%; + + " + + > + + 자재번호 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 15%; + + " + + > + + 자재상세 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 5%; + + " + + > + + 연간단가 여부 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 5%; + + " + + > + + 수량 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 5%; + + " + + > + + 구매단위 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 10%; + + " + + > + + 계약단가 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 5%; + + " + + > + + 수량단위 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 5%; + + " + + > + + 총중량 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 5%; + + " + + > + + 중량단위 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 10%; + + " + + > + + 계약금액 + + </th> + + </tr> + + </thead> + + <tbody> + + <!-- 데이터 행 (반복 영역) --> + + <tr style="font-size: 12px;"> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">1</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{플랜트_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_1}}</td> + + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재번호_1}}</td> + + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재상세_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{연간단가여부_1}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_1}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약단가_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_1}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_1}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc; font-weight: 600;">{{계약금액_1}}</td> + + </tr> + + <tr style="font-size: 12px;"> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">2</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{플랜트_2}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_2}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_2}}</td> + + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_2}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재번호_2}}</td> + + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재상세_2}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{연간단가여부_2}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_2}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_2}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약단가_2}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_2}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_2}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_2}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc; font-weight: 600;">{{계약금액_2}}</td> + + </tr> + + <!-- /데이터 행 --> + + <tr> + + <td colspan="14" style="background-color: #f5f5f5; padding: 8px 10px; text-align: center; font-weight: 700; border: 1px solid #ccc;">총 계약 금액</td> + + <td style="padding: 8px 10px; text-align: right; font-weight: 700; border: 1px solid #ccc;">{{총_계약금액}}</td> + + </tr> + + </tbody> + + </table> + + + + <!-- 5. 보증 내용 --> + + <!-- <table + + style=" + + width: 100%; + + border-collapse: collapse; + + margin-bottom: 15px; + + " + + > + + <thead> + + <tr> + + <th + + colspan="10" + + style=" + + background-color: #333; + + color: #fff; + + padding: 10px; + + text-align: left; + + font-size: 15px; + + font-weight: 600; + + border-bottom: 1px solid #666; + + " + + > + + ■ 보증 내용 + + </th> + + </tr> + + <tr style="font-size: 12px;"> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 10%; + + " + + > + + 구분 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 5%; + + " + + > + + 차수 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 15%; + + " + + > + + 증권번호 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 7%; + + " + + > + + 보증금율(%) + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 13%; + + " + + > + + 보증 금액 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 10%; + + " + + > + + 보증 기간 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 10%; + + " + + > + + 보증기간 시작일 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 10%; + + " + + > + + 보증기간 종료일 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 15%; + + " + + > + + 발행 기관 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 5%; + + " + + > + + 발행<br>비고<br>지 + + </th> + + </tr> + + </thead> + + <tbody> + + + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + 계약보증 + + </td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_차수_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_증권번호_1}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약보증_보증금율_1}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약보증_보증금액_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_보증기간_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_시작일_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_종료일_1}}</td> + + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{계약보증_발행기관_1}}</td> + + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{계약보증_비고_1}}</td> + + </tr> + + + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + 지급보증 + + </td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_차수_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_증권번호_1}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{지급보증_보증금율_1}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{지급보증_보증금액_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_보증기간_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_시작일_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_종료일_1}}</td> + + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{지급보증_발행기관_1}}</td> + + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{지급보증_비고_1}}</td> + + </tr> + + + + <tr> + + <td + + style=" + + background-color: #f5f5f5; + + padding: 8px 10px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + 하자보증 + + </td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_차수_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_증권번호_1}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{하자보증_보증금율_1}}</td> + + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{하자보증_보증금액_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_보증기간_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_시작일_1}}</td> + + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_종료일_1}}</td> + + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{하자보증_발행기관_1}}</td> + + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{하자보증_비고_1}}</td> + + </tr> + + </tbody> + + </table> + +--> + + + + <!-- 6. 하도급 자율점검 Check List --> + + <table + + style=" + + width: 100%; + + border-collapse: collapse; + + margin-bottom: 15px; + + " + + > + + <thead> + + <tr> + + <th + + colspan="12" + + style=" + + background-color: #333; + + color: #fff; + + padding: 10px; + + text-align: left; + + font-size: 15px; + + font-weight: 600; + + border-bottom: 1px solid #666; + + " + + > + + ■ 하도급 자율점검 Check List + + </th> + + </tr> + + <!-- 헤더 1행: 계약 시 --> + + <tr style="font-size: 12px;"> + + <th + + colspan="12" + + style=" + + background-color: #d9d9d9; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + 계약 시 [계약체결 단계] + + </th> + + </tr> + + <!-- 헤더 2행 ~ 4행 (복합 구조) --> + + <tr style="font-size: 12px;"> + + <!-- 작업 前 서면발급 --> + + <th + + rowspan="3" + + style=" + + background-color: #d9d9d9; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 5%; + + " + + > + + 작업 前<br>서면발급 + + </th> + + <!-- 1. 계약서면 발급 --> + + <th + + colspan="6" + + style=" + + background-color: #d9d9d9; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 30%; + + " + + > + + 1. 계약서면 발급 + + </th> + + <!-- 2. 부당 하도급 대금 결정 행위 --> + + <th + + rowspan="3" + + style=" + + background-color: #d9d9d9; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 10%; + + " + + > + + 2. 부당하도급대<br>금 결정 행위<br>(대금결정방법) + + </th> + + <!-- 점검 결과 --> + + <th + + rowspan="3" + + style=" + + background-color: #d9d9d9; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 10%; + + " + + > + + 점검결과<br>"준수"<br>"위반"<br>"위반의심" + + </th> + + <!-- 위반/위반의심 시 작성 항목 --> + + <th + + colspan="3" + + rowspan="2" + + style=" + + background-color: #d9d9d9; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + width: 45%; + + " + + > + + 위반/위반의심 시 a~c 작성 欄 + + </th> + + </tr> + + <tr style="font-size: 12px;"> + + <!-- 6대 법정 기재사항 --> + + <th + + colspan="6" + + style=" + + background-color: #d9d9d9; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + 6대 법정 기재사항 명기 여부 + + </th> + + </tr> + + <tr style="font-size: 12px;"> + + <!-- 1~6 항목 --> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + ①위탁일자<br>/위탁내용 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + ②인도시기<br>/장소 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + ③검사방법<br>/시기 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + ④대금지급<br>방법/기일 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + ⑤원재료지급<br>방법/기일 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + ⑥원재료가격변동<br>에 따른 대금조정 등 + + </th> + + <!-- a, b, c --> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + a. 귀책부서 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + b. 원인 + + </th> + + <th + + style=" + + background-color: #e8e8e8; + + padding: 6px 4px; + + text-align: center; + + font-weight: 600; + + border: 1px solid #ccc; + + " + + > + + c. 대책 + + </th> + + </tr> + + </thead> + + <tbody> + + <!-- 데이터 행 --> + + <tr style="font-size: 12px;"> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{작업전_서면발급_체크}} + + </td> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{기재사항_1}} + + </td> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{기재사항_2}} + + </td> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{기재사항_3}} + + </td> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{기재사항_4}} + + </td> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{기재사항_5}} + + </td> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{기재사항_6}} + + </td> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{부당대금_결정}} + + </td> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{점검결과}} + + </td> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{귀책부서}} + + </td> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{원인}} + + </td> + + <td + + style=" + + padding: 6px 4px; + + text-align: center; + + border: 1px solid #ccc; + + " + + > + + {{대책}} + + </td> + + </tr> + + </tbody> + + </table> + +</div> + 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..77e36bc7 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" @@ -81,47 +82,17 @@ 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) - // 협의 중 (negotiationCompletedAt 없고 코멘트 있음)은 불가 - const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => { - const contract = row.original; - // 이미 법무검토 요청된 계약서는 제외 - if (contract.legalReviewRequestedAt) { - return false; - } - // 이미 최종승인 완료된 계약서는 제외 - if (contract.completedAt) { - return false; - } - - // 협의 완료된 경우 → 가능 - if (contract.negotiationCompletedAt) { - return true; - } - - // 협의 완료되지 않은 경우 - // GTC 템플릿인 경우 코멘트 존재 여부 확인 - if (contract.templateName?.includes('GTC')) { - const contractGtcData = gtcData[contract.id]; - // 코멘트가 없으면 가능 (협의 없음) - if (contractGtcData && !contractGtcData.hasComments) { - return true; - } - // 코멘트가 있으면 불가 (협의 중) - return false; - } - - // GTC가 아닌 경우는 협의 완료 여부만 확인 - return false; - }); + // 법무검토 요청 가능 여부: 준법서약 템플릿이 아닐 때 항상 활성화 + const canRequestLegalReview = !isComplianceTemplate; + + // 준법문의 버튼 활성화 가능 여부: 준법서약 템플릿일 때 항상 활성화 + const canRequestComplianceInquiry = isComplianceTemplate; // 필터링된 계약서들 계산 const resendContracts = selectedRows.map(row => row.original) @@ -394,6 +365,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,11 +553,34 @@ export function BasicContractDetailTableToolbarActions({ const complianceInquiryUrl = 'http://60.101.207.55/Inquiry/Write/InquiryWrite.aspx' // 법무검토 요청 / 준법문의 - const handleRequestLegalReview = () => { + const handleRequestLegalReview = async () => { if (isComplianceTemplate) { + // 준법문의: 선택된 계약서가 있으면 요청일 기록 후 외부 URL 열기, 없으면 URL만 열기 + const selectedContractIds = selectedRows.map(row => row.original.id) + + if (selectedContractIds.length > 0) { + try { + setLoading(true) + const result = await requestComplianceInquiryAction(selectedContractIds) + if (result.success) { + toast.success(result.message) + router.refresh() + } else { + toast.error(result.message) + } + } catch (error) { + console.error('준법문의 요청 처리 실패:', error) + toast.error('준법문의 요청 중 오류가 발생했습니다.') + } finally { + setLoading(false) + } + } + + // 선택된 계약서가 있든 없든 URL은 항상 열기 window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer') return } + // 법무검토 요청: 선택된 행이 없어도 다이얼로그 열기 setLegalReviewDialog(true) } @@ -617,31 +652,64 @@ 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={loading} + title="준법문의 링크로 이동" + > + <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={loading} + title="법무검토 요청 링크 선택" + > + <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..64dc3aa8 100644 --- a/lib/bidding/actions.ts +++ b/lib/bidding/actions.ts @@ -20,7 +20,7 @@ import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po-bidding" import { getCurrentSAPDate } from "@/lib/soap/utils" import { generateContractNumber } from "@/lib/general-contracts/service" import { saveFile } from "@/lib/file-stroage" - +import { checkAndSaveChemicalSubstancesForBidding } from "./service" // TO Contract export async function transmitToContract(biddingId: number, userId: number) { try { @@ -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 }) @@ -644,7 +652,7 @@ export async function cancelDisposalAction( } // 사용자 이름 조회 헬퍼 함수 -async function getUserNameById(userId: string): Promise<string> { +export async function getUserNameById(userId: string): Promise<string> { try { const user = await db .select({ name: users.name }) @@ -730,6 +738,26 @@ export async function openBiddingAction(biddingId: number) { }) .where(eq(biddings.id, biddingId)) + // 4. 화학물질 조회 실행 (비동기로 실행해서 개찰 성능에 영향 없도록) + try { + // 개찰 트랜잭션이 완료된 후 화학물질 조회 시작 + setImmediate(async () => { + try { + const result = await checkAndSaveChemicalSubstancesForBidding(biddingId) + if (result.success) { + console.log(`입찰 ${biddingId} 화학물질 조회 완료: ${result.results.filter(r => r.success).length}/${result.results.length}개 업체`) + } else { + console.error(`입찰 ${biddingId} 화학물질 조회 실패:`, result.message) + } + } catch (error) { + console.error(`입찰 ${biddingId} 화학물질 조회 중 오류:`, error) + } + }) + } catch (error) { + // 화학물질 조회 실패해도 개찰은 성공으로 처리 + console.error('화학물질 조회 시작 실패:', error) + } + return { success: true, message: isDeadlinePassed ? '개찰이 완료되었습니다.' : '조기개찰이 완료되었습니다.' } }) diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts index 3d07d49c..b4f6f297 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, @@ -264,12 +266,14 @@ export async function requestBiddingInvitationWithApproval(data: { const { default: db } = await import('@/db/db'); const { biddings, biddingCompanies, prItemsForBidding } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); - + const { getUserNameById } = await import('@/lib/bidding/actions'); + const userName = await getUserNameById(data.currentUser.id.toString()); + await db .update(biddings) .set({ status: 'approval_pending', // 결재 진행중 상태 - updatedBy: String(data.currentUser.id), // id를 string으로 변환 + updatedBy: userName, updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); @@ -463,6 +467,7 @@ export async function requestBiddingClosureWithApproval(data: { const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); + const { getUserNameById } = await import('@/lib/bidding/actions'); // 유찰상태인지 확인 const biddingResult = await db @@ -485,12 +490,12 @@ export async function requestBiddingClosureWithApproval(data: { // 3. 입찰 상태를 결재 진행중으로 변경 debugLog('[BiddingClosureApproval] 입찰 상태 변경 시작'); - + const userName = await getUserNameById(data.currentUser.id.toString()); await db .update(biddings) .set({ status: 'approval_pending', // 폐찰 결재 진행중 상태 - updatedBy: Number(data.currentUser.id), + updatedBy: userName, updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); @@ -691,12 +696,13 @@ export async function requestBiddingAwardWithApproval(data: { const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); - + const { getUserNameById } = await import('@/lib/bidding/actions'); + const userName = await getUserNameById(data.currentUser.id.toString()); await db .update(biddings) .set({ status: 'approval_pending', // 낙찰 결재 진행중 상태 - updatedBy: Number(data.currentUser.id), + updatedBy: userName, updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index f52ecb1e..eec3f253 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' @@ -30,43 +30,113 @@ async function getUserNameById(userId: string): Promise<string> { // 데이터 조회 함수들 export interface BiddingDetailData { bidding: Awaited<ReturnType<typeof getBiddingById>> - quotationDetails: QuotationDetails | null + quotationDetails: null quotationVendors: QuotationVendor[] - prItems: Awaited<ReturnType<typeof getPRItemsForBidding>> + prItems: Awaited<ReturnType<typeof getPrItemsForBidding>> } // getBiddingById 함수 임포트 (기존 함수 재사용) import { getBiddingById, updateBiddingProjectInfo } from '@/lib/bidding/service' +import { getPrItemsForBidding } from '../pre-quote/service' -// Promise.all을 사용하여 모든 데이터를 병렬로 조회 (캐시 적용) +// Bidding Detail Data 조회 (캐시 제거, 로직 단순화) export async function getBiddingDetailData(biddingId: number): Promise<BiddingDetailData> { - return unstable_cache( - async () => { - const [ - bidding, - quotationDetails, - quotationVendors, - prItems - ] = await Promise.all([ - getBiddingById(biddingId), - getQuotationDetails(biddingId), - getQuotationVendors(biddingId), - getPRItemsForBidding(biddingId) - ]) + try { + // 1. 입찰 정보 조회 + const bidding = await getBiddingById(biddingId) - return { - bidding, - quotationDetails, - quotationVendors, - prItems + // 2. 입찰 품목 조회 (pre-quote service 함수 재사용) + const prItems = await getPrItemsForBidding(biddingId) + + // 3. 본입찰 제출 업체 조회 (bidding_submitted 상태) + const vendorsData = await db + .select({ + id: biddingCompanies.id, + biddingId: biddingCompanies.biddingId, + vendorId: biddingCompanies.companyId, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + vendorEmail: vendors.email, + quotationAmount: biddingCompanies.finalQuoteAmount, + currency: sql<string>`'KRW'`, + submissionDate: biddingCompanies.finalQuoteSubmittedAt, + isWinner: biddingCompanies.isWinner, + awardRatio: biddingCompanies.awardRatio, + isBiddingParticipated: biddingCompanies.isBiddingParticipated, + invitationStatus: biddingCompanies.invitationStatus, + // 연동제 관련 필드 + isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion, + priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부 + shiPriceAdjustmentApplied: biddingCompanies.shiPriceAdjustmentApplied, + priceAdjustmentNote: biddingCompanies.priceAdjustmentNote, + hasChemicalSubstance: biddingCompanies.hasChemicalSubstance, + // Contact info from biddingCompaniesContacts + contactPerson: biddingCompaniesContacts.contactName, + contactEmail: biddingCompaniesContacts.contactEmail, + contactPhone: biddingCompaniesContacts.contactNumber, + }) + .from(biddingCompanies) + .innerJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .leftJoin(biddingCompaniesContacts, and( + eq(biddingCompaniesContacts.biddingId, biddingId), + eq(biddingCompaniesContacts.vendorId, biddingCompanies.companyId) + )) + .leftJoin(companyConditionResponses, and( + eq(companyConditionResponses.biddingCompanyId, biddingCompanies.id), + eq(companyConditionResponses.isPreQuote, false) // 본입찰 데이터만 + )) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isBiddingParticipated, true) + )) + .orderBy(desc(biddingCompanies.finalQuoteAmount)) + + // 중복 제거 (업체당 여러 담당자가 있을 경우 첫 번째만 사용하거나 처리) + // 여기서는 간단히 메모리에서 중복 제거 (biddingCompanyId 기준) + const uniqueVendors = vendorsData.reduce((acc, curr) => { + if (!acc.find(v => v.id === curr.id)) { + acc.push({ + id: curr.id, + biddingId: curr.biddingId, + vendorId: curr.vendorId, + vendorName: curr.vendorName || `Vendor ${curr.vendorId}`, + vendorCode: curr.vendorCode || '', + vendorEmail: curr.vendorEmail || '', + contactPerson: curr.contactPerson || '', + contactEmail: curr.contactEmail || '', + contactPhone: curr.contactPhone || '', + quotationAmount: Number(curr.quotationAmount) || 0, + currency: curr.currency, + submissionDate: curr.submissionDate ? (curr.submissionDate instanceof Date ? curr.submissionDate.toISOString().split('T')[0] : String(curr.submissionDate).split('T')[0]) : '', + isWinner: curr.isWinner, + awardRatio: curr.awardRatio ? Number(curr.awardRatio) : null, + isBiddingParticipated: curr.isBiddingParticipated, + invitationStatus: curr.invitationStatus, + // 연동제 관련 필드 + isPriceAdjustmentApplicableQuestion: curr.isPriceAdjustmentApplicableQuestion, + priceAdjustmentResponse: curr.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부 + shiPriceAdjustmentApplied: curr.shiPriceAdjustmentApplied, + priceAdjustmentNote: curr.priceAdjustmentNote, + hasChemicalSubstance: curr.hasChemicalSubstance, + documents: [], + }) } - }, - [`bidding-detail-data-${biddingId}`], - { - tags: [`bidding-${biddingId}`, 'bidding-detail', 'quotation-vendors', 'pr-items'] + return acc + }, [] as QuotationVendor[]) + + return { + bidding, + quotationDetails: null, + quotationVendors: uniqueVendors, + prItems } - )() + } catch (error) { + console.error('Failed to get bidding detail data:', error) + throw error + } } + +// QuotationDetails Interface (Keeping it for type safety if needed elsewhere, or remove if safe) export interface QuotationDetails { biddingId: number estimatedPrice: number // 예상액 @@ -94,6 +164,12 @@ export interface QuotationVendor { awardRatio: number | null // 발주비율 isBiddingParticipated: boolean | null // 본입찰 참여여부 invitationStatus: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted' + // 연동제 관련 필드 + isPriceAdjustmentApplicableQuestion: boolean | null // SHI가 요청한 연동제 요청 여부 + priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부 (companyConditionResponses.priceAdjustmentResponse) + shiPriceAdjustmentApplied: boolean | null // SHI 연동제 적용여부 + priceAdjustmentNote: string | null // 연동제 Note + hasChemicalSubstance: boolean | null // 화학물질여부 documents: Array<{ id: number fileName: string @@ -103,66 +179,6 @@ export interface QuotationVendor { }> } -// 견적 시스템에서 내정가 및 관련 정보를 가져오는 함수 (캐시 적용) -export async function getQuotationDetails(biddingId: number): Promise<QuotationDetails | null> { - return unstable_cache( - async () => { - try { - // bidding_companies 테이블에서 견적 데이터를 집계 - const quotationStats = await db - .select({ - biddingId: biddingCompanies.biddingId, - estimatedPrice: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('estimated_price'), - lowestQuote: sql<number>`MIN(${biddingCompanies.finalQuoteAmount})`.as('lowest_quote'), - averageQuote: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('average_quote'), - targetPrice: sql<number>`AVG(${biddings.targetPrice})`.as('target_price'), - quotationCount: sql<number>`COUNT(*)`.as('quotation_count'), - lastUpdated: sql<string>`MAX(${biddingCompanies.updatedAt})`.as('last_updated') - }) - .from(biddingCompanies) - .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) - .where(and( - eq(biddingCompanies.biddingId, biddingId), - sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL` - )) - .groupBy(biddingCompanies.biddingId) - .limit(1) - - if (quotationStats.length === 0) { - return { - biddingId, - estimatedPrice: 0, - lowestQuote: 0, - averageQuote: 0, - targetPrice: 0, - quotationCount: 0, - lastUpdated: new Date().toISOString() - } - } - - const stat = quotationStats[0] - - return { - biddingId, - estimatedPrice: Number(stat.estimatedPrice) || 0, - lowestQuote: Number(stat.lowestQuote) || 0, - averageQuote: Number(stat.averageQuote) || 0, - targetPrice: Number(stat.targetPrice) || 0, - quotationCount: Number(stat.quotationCount) || 0, - lastUpdated: stat.lastUpdated || new Date().toISOString() - } - } catch (error) { - console.error('Failed to get quotation details:', error) - return null - } - }, - [`quotation-details-${biddingId}`], - { - tags: [`bidding-${biddingId}`, 'quotation-details'] - } - )() -} - // bidding_companies 테이블을 메인으로 vendors 테이블을 조인하여 협력업체 정보 조회 export async function getBiddingCompaniesData(biddingId: number) { try { @@ -281,7 +297,7 @@ export async function getAllBiddingCompanies(biddingId: number) { } } -// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 미적용, always fresh) +// prItemsForBidding 테이블에서 품목 정보 조회 (deprecated - import from pre-quote/service) export async function getPRItemsForBidding(biddingId: number) { try { const items = await db @@ -297,70 +313,9 @@ export async function getPRItemsForBidding(biddingId: number) { } } -// 견적 시스템에서 협력업체 정보를 가져오는 함수 (캐시 적용) -export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> { - return unstable_cache( - async () => { - try { - // bidding_companies 테이블을 메인으로 vendors를 조인하여 협력업체 정보 조회 - const vendorsData = await db - .select({ - id: biddingCompanies.id, - biddingId: biddingCompanies.biddingId, - vendorId: biddingCompanies.companyId, - vendorName: vendors.vendorName, - vendorCode: vendors.vendorCode, - vendorEmail: vendors.email, // 벤더의 기본 이메일 - contactPerson: biddingCompanies.contactPerson, - contactEmail: biddingCompanies.contactEmail, - contactPhone: biddingCompanies.contactPhone, - quotationAmount: biddingCompanies.finalQuoteAmount, - currency: sql<string>`'KRW'`, - submissionDate: biddingCompanies.finalQuoteSubmittedAt, - isWinner: biddingCompanies.isWinner, - // awardRatio: sql<number>`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`, - awardRatio: biddingCompanies.awardRatio, - isBiddingParticipated: biddingCompanies.isBiddingParticipated, - invitationStatus: biddingCompanies.invitationStatus, - }) - .from(biddingCompanies) - .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) - .where(and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.isPreQuoteSelected, true) // 본입찰 선정된 업체만 조회 - )) - .orderBy(desc(biddingCompanies.finalQuoteAmount)) +// 견적 시스템에서 협력업체 정보를 가져오는 함수 (Deprecated - integrated into getBiddingDetailData) +// export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> { ... } - return vendorsData.map(vendor => ({ - id: vendor.id, - biddingId: vendor.biddingId, - vendorId: vendor.vendorId, - vendorName: vendor.vendorName || `Vendor ${vendor.vendorId}`, - vendorCode: vendor.vendorCode || '', - vendorEmail: vendor.vendorEmail || '', // 벤더의 기본 이메일 - contactPerson: vendor.contactPerson || '', - contactEmail: vendor.contactEmail || '', - contactPhone: vendor.contactPhone || '', - quotationAmount: Number(vendor.quotationAmount) || 0, - currency: vendor.currency, - submissionDate: vendor.submissionDate ? (vendor.submissionDate instanceof Date ? vendor.submissionDate.toISOString().split('T')[0] : String(vendor.submissionDate).split('T')[0]) : '', - isWinner: vendor.isWinner, - awardRatio: vendor.awardRatio ? Number(vendor.awardRatio) : null, - isBiddingParticipated: vendor.isBiddingParticipated, - invitationStatus: vendor.invitationStatus, - documents: [], // 빈 배열로 초기화 - })) - } catch (error) { - console.error('Failed to get quotation vendors:', error) - return [] - } - }, - [`quotation-vendors-${biddingId}`], - { - tags: [`bidding-${biddingId}`, 'quotation-vendors'] - } - )() -} // 사전견적 데이터 조회 (내정가 산정용) export async function getPreQuoteData(biddingId: number) { @@ -898,11 +853,59 @@ export async function registerBidding(biddingId: number, userId: string) { await db.transaction(async (tx) => { debugLog('registerBidding: Transaction started') - // 1. 입찰 상태를 오픈으로 변경 + + // 0. 입찰서 제출기간 계산 (입력값 절대 기준) + const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = bidding + + let calculatedStartDate = bidding.submissionStartDate + let calculatedEndDate = bidding.submissionEndDate + + if (submissionStartOffset !== null && submissionDurationDays !== null) { + // DB에 저장된 시간을 숫자 그대로 가져옴 (예: 10:00 저장 → 10 반환) + const startTime = submissionStartDate + ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() } + : { hours: 9, minutes: 0 } + const endTime = submissionEndDate + ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() } + : { hours: 18, minutes: 0 } + + // 서버의 오늘 날짜(년/월/일)를 그대로 사용해 00:00 UTC 시점 생성 + const now = new Date() + const baseDate = new Date(Date.UTC( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 0, 0, 0 + )) + + // 시작일 = baseDate + offset일 + 입력 시간(숫자 그대로) + const tempStartDate = new Date(baseDate) + tempStartDate.setUTCDate(tempStartDate.getUTCDate() + submissionStartOffset) + tempStartDate.setUTCHours(startTime.hours, startTime.minutes, 0, 0) + + // 마감일 = 시작일 날짜만 기준 + duration일 + 입력 마감 시간 + const tempEndDate = new Date(tempStartDate) + tempEndDate.setUTCHours(0, 0, 0, 0) + tempEndDate.setUTCDate(tempEndDate.getUTCDate() + submissionDurationDays) + tempEndDate.setUTCHours(endTime.hours, endTime.minutes, 0, 0) + + calculatedStartDate = tempStartDate + calculatedEndDate = tempEndDate + + debugLog('registerBidding: Submission dates calculated (Input Value Based)', { + baseDate: baseDate.toISOString(), + calculatedStartDate: calculatedStartDate.toISOString(), + calculatedEndDate: calculatedEndDate.toISOString(), + }) + } + + // 1. 입찰 상태를 오픈으로 변경 + 제출기간 업데이트 await tx .update(biddings) .set({ status: 'bidding_opened', + submissionStartDate: calculatedStartDate, + submissionEndDate: calculatedEndDate, updatedBy: userName, updatedAt: new Date() }) @@ -1368,10 +1371,14 @@ export async function getAwardedCompanies(biddingId: number) { companyId: biddingCompanies.companyId, companyName: vendors.vendorName, finalQuoteAmount: biddingCompanies.finalQuoteAmount, - awardRatio: biddingCompanies.awardRatio + awardRatio: biddingCompanies.awardRatio, + vendorCode: vendors.vendorCode, + companySize: vendors.businessSize, + targetPrice: biddings.targetPrice }) .from(biddingCompanies) .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) .where(and( eq(biddingCompanies.biddingId, biddingId), eq(biddingCompanies.isWinner, true) @@ -1381,7 +1388,10 @@ export async function getAwardedCompanies(biddingId: number) { companyId: company.companyId, companyName: company.companyName, finalQuoteAmount: parseFloat(company.finalQuoteAmount?.toString() || '0'), - awardRatio: parseFloat(company.awardRatio?.toString() || '0') + awardRatio: parseFloat(company.awardRatio?.toString() || '0'), + vendorCode: company.vendorCode, + companySize: company.companySize, + targetPrice: company.targetPrice ? parseFloat(company.targetPrice.toString()) : 0 })) } catch (error) { console.error('Failed to get awarded companies:', error) @@ -1410,7 +1420,7 @@ async function updateBiddingAmounts(biddingId: number) { .set({ targetPrice: totalTargetAmount.toString(), budget: totalBudgetAmount.toString(), - finalBidPrice: totalActualAmount.toString(), + actualPrice: totalActualAmount.toString(), updatedAt: new Date() }) .where(eq(biddings.id, biddingId)) @@ -1693,7 +1703,7 @@ export interface PartnersBiddingListItem { biddingNumber: string originalBiddingNumber: string | null // 원입찰번호 revision: number | null - projectName: string + projectName: string | null itemName: string title: string contractType: string @@ -1782,9 +1792,9 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part // 계산된 필드 추가 const resultWithCalculatedFields = result.map(item => ({ ...item, - respondedAt: item.respondedAt ? (item.respondedAt instanceof Date ? item.respondedAt.toISOString() : item.respondedAt.toString()) : null, + respondedAt: item.respondedAt ? (item.respondedAt instanceof Date ? item.respondedAt.toISOString() : String(item.respondedAt)) : null, finalQuoteAmount: item.finalQuoteAmount ? Number(item.finalQuoteAmount) : null, // string을 number로 변환 - finalQuoteSubmittedAt: item.finalQuoteSubmittedAt ? (item.finalQuoteSubmittedAt instanceof Date ? item.finalQuoteSubmittedAt.toISOString() : item.finalQuoteSubmittedAt.toString()) : null, + finalQuoteSubmittedAt: item.finalQuoteSubmittedAt ? (item.finalQuoteSubmittedAt instanceof Date ? item.finalQuoteSubmittedAt.toISOString() : String(item.finalQuoteSubmittedAt)) : null, responseDeadline: item.submissionStartDate ? new Date(item.submissionStartDate.getTime() - 3 * 24 * 60 * 60 * 1000) // 3일 전 : null, @@ -1825,7 +1835,6 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId: biddingRegistrationDate: biddings.biddingRegistrationDate, submissionStartDate: biddings.submissionStartDate, submissionEndDate: biddings.submissionEndDate, - evaluationDate: biddings.evaluationDate, // 가격 정보 currency: biddings.currency, @@ -2596,101 +2605,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 [] + } } // 사양설명회 참여 여부 업데이트 @@ -2720,3 +2700,35 @@ export async function setSpecificationMeetingParticipation(biddingCompanyId: num return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' } } } + +// 연동제 정보 업데이트 +export async function updatePriceAdjustmentInfo(params: { + biddingCompanyId: number + shiPriceAdjustmentApplied: boolean | null + priceAdjustmentNote: string | null + hasChemicalSubstance: boolean | null +}): Promise<{ success: boolean; error?: string }> { + try { + const result = await db.update(biddingCompanies) + .set({ + shiPriceAdjustmentApplied: params.shiPriceAdjustmentApplied, + priceAdjustmentNote: params.priceAdjustmentNote, + hasChemicalSubstance: params.hasChemicalSubstance, + updatedAt: new Date(), + }) + .where(eq(biddingCompanies.id, params.biddingCompanyId)) + .returning({ biddingId: biddingCompanies.biddingId }) + + if (result.length > 0) { + const biddingId = result[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidatePath(`/evcp/bid/${biddingId}`) + } + + return { success: true } + } catch (error) { + console.error('Failed to update price adjustment info:', error) + return { success: false, error: '연동제 정보 업데이트에 실패했습니다.' } + } +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 5368b287..05c1a93d 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -31,6 +31,7 @@ interface GetVendorColumnsProps { } export function getBiddingDetailVendorColumns({ + onViewPriceAdjustment, onViewItemDetails, onSendBidding, onUpdateParticipation, @@ -239,6 +240,83 @@ export function getBiddingDetailVendorColumns({ ), }, { + accessorKey: 'priceAdjustmentResponse', + header: '연동제 응답', + cell: ({ row }) => { + const vendor = row.original + const response = vendor.priceAdjustmentResponse + + // 버튼 형태로 표시, 클릭 시 상세 다이얼로그 열기 + const getBadgeVariant = () => { + if (response === null || response === undefined) return 'outline' + return response ? 'default' : 'secondary' + } + + const getBadgeClass = () => { + if (response === true) return 'bg-green-600 hover:bg-green-700 cursor-pointer' + if (response === false) return 'hover:bg-gray-300 cursor-pointer' + return '' + } + + const getLabel = () => { + if (response === null || response === undefined) return '해당없음' + return response ? '예' : '아니오' + } + + return ( + <Badge + variant={getBadgeVariant()} + className={getBadgeClass()} + onClick={() => onViewPriceAdjustment?.(vendor)} + > + {getLabel()} + </Badge> + ) + }, + }, + { + accessorKey: 'shiPriceAdjustmentApplied', + header: 'SHI연동제적용', + cell: ({ row }) => { + const applied = row.original.shiPriceAdjustmentApplied + if (applied === null || applied === undefined) { + return <Badge variant="outline">미정</Badge> + } + return ( + <Badge variant={applied ? 'default' : 'secondary'} className={applied ? 'bg-green-600' : ''}> + {applied ? '적용' : '미적용'} + </Badge> + ) + }, + }, + { + accessorKey: 'priceAdjustmentNote', + header: '연동제 Note', + cell: ({ row }) => { + const note = row.original.priceAdjustmentNote + return ( + <div className="text-sm max-w-[150px] truncate" title={note || ''}> + {note || '-'} + </div> + ) + }, + }, + { + accessorKey: 'hasChemicalSubstance', + header: '화학물질', + cell: ({ row }) => { + const hasChemical = row.original.hasChemicalSubstance + if (hasChemical === null || hasChemical === undefined) { + return <Badge variant="outline">미정</Badge> + } + return ( + <Badge variant={hasChemical ? 'destructive' : 'secondary'}> + {hasChemical ? '해당' : '해당없음'} + </Badge> + ) + }, + }, + { id: 'actions', header: '작업', cell: ({ row }) => { diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index fffac0c1..407cc51c 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -10,9 +10,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog' import { BiddingAwardDialog } from './bidding-award-dialog' import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns' -import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service' +import { QuotationVendor } from '@/lib/bidding/detail/service' import { Bidding } from '@/db/schema' -import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog' +import { VendorPriceAdjustmentViewDialog } from './vendor-price-adjustment-view-dialog' import { QuotationHistoryDialog } from './quotation-history-dialog' import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog' import { ApplicationReasonDialog } from '@/lib/rfq-last/vendor/application-reason-dialog' @@ -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() @@ -96,8 +98,7 @@ export function BiddingDetailVendorTableContent({ const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null) const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false) const [isAwardRatioDialogOpen, setIsAwardRatioDialogOpen] = React.useState(false) - const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null) - const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) + const [isVendorPriceAdjustmentDialogOpen, setIsVendorPriceAdjustmentDialogOpen] = React.useState(false) const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null) const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false) const [approvalPreviewData, setApprovalPreviewData] = React.useState<{ @@ -114,28 +115,9 @@ export function BiddingDetailVendorTableContent({ } | null>(null) const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false) - const handleViewPriceAdjustment = async (vendor: QuotationVendor) => { - try { - const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(vendor.id) - if (priceAdjustmentForm) { - setPriceAdjustmentData(priceAdjustmentForm) - setSelectedVendor(vendor) - setIsPriceAdjustmentDialogOpen(true) - } else { - toast({ - title: '연동제 정보 없음', - description: '해당 업체의 연동제 정보가 없습니다.', - variant: 'default', - }) - } - } catch (error) { - console.error('Failed to load price adjustment form:', error) - toast({ - title: '오류', - description: '연동제 정보를 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } + const handleViewPriceAdjustment = (vendor: QuotationVendor) => { + setSelectedVendor(vendor) + setIsVendorPriceAdjustmentDialogOpen(true) } const handleViewQuotationHistory = async (vendor: QuotationVendor) => { @@ -269,6 +251,7 @@ export function BiddingDetailVendorTableContent({ onSuccess={onRefresh} winnerVendor={vendors.find(v => v.awardRatio === 100)} singleSelectedVendor={singleSelectedVendor} + readOnly={readOnly} /> </DataTableAdvancedToolbar> </DataTable> @@ -296,11 +279,12 @@ export function BiddingDetailVendorTableContent({ }} /> - <PriceAdjustmentDialog - open={isPriceAdjustmentDialogOpen} - onOpenChange={setIsPriceAdjustmentDialogOpen} - data={priceAdjustmentData} + <VendorPriceAdjustmentViewDialog + open={isVendorPriceAdjustmentDialogOpen} + onOpenChange={setIsVendorPriceAdjustmentDialogOpen} vendorName={selectedVendor?.vendorName || ''} + priceAdjustmentResponse={selectedVendor?.priceAdjustmentResponse ?? null} + biddingCompanyId={selectedVendor?.id || 0} /> <QuotationHistoryDialog 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..e934a5fe 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -5,13 +5,14 @@ import { useRouter } from "next/navigation" import { useTransition } from "react" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react" +import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw, Link2 } from "lucide-react" import { registerBidding, markAsDisposal, cancelAwardRatio } from "@/lib/bidding/detail/service" import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" import { increaseRoundOrRebid } from "@/lib/bidding/service" import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/bidding-detail-vendor-create-dialog" import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog" +import { PriceAdjustmentDialog } from "./price-adjustment-dialog" import { Bidding } from "@/db/schema" import { useToast } from "@/hooks/use-toast" import { QuotationVendor } from "@/lib/bidding/detail/service" @@ -25,6 +26,7 @@ interface BiddingDetailVendorToolbarActionsProps { onSuccess: () => void winnerVendor?: QuotationVendor | null // 100% 낙찰된 벤더 singleSelectedVendor?: QuotationVendor | null // single select된 벤더 + readOnly?: boolean } export function BiddingDetailVendorToolbarActions({ @@ -35,7 +37,8 @@ export function BiddingDetailVendorToolbarActions({ onOpenAwardRatioDialog, onSuccess, winnerVendor, - singleSelectedVendor + singleSelectedVendor, + readOnly = false }: BiddingDetailVendorToolbarActionsProps) { const router = useRouter() const { toast } = useToast() @@ -47,6 +50,7 @@ export function BiddingDetailVendorToolbarActions({ const [selectedVendors, setSelectedVendors] = React.useState<any[]>([]) const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false) const [isCancelAwardDialogOpen, setIsCancelAwardDialogOpen] = React.useState(false) + const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 React.useEffect(() => { @@ -82,53 +86,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 +122,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 @@ -218,8 +154,12 @@ export function BiddingDetailVendorToolbarActions({ title: "성공", description: '차수증가가 완료되었습니다.', }) - router.push(`/evcp/bid`) - onSuccess() + if (result.biddingId) { + router.push(`/evcp/bid/${result.biddingId}/info`) + } else { + router.push(`/evcp/bid`) + } + // onSuccess() } else { toast({ title: "오류", @@ -233,69 +173,87 @@ 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> + )} + + {/* 연동제 적용여부: single select 시에만 활성화 */} + {(bidding.status === 'evaluation_of_bidding') && ( + <Button + variant="outline" + size="sm" + onClick={() => setIsPriceAdjustmentDialogOpen(true)} + disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true} + > + <Link2 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') && ( @@ -392,6 +350,14 @@ export function BiddingDetailVendorToolbarActions({ </DialogContent> </Dialog> + {/* 연동제 적용여부 다이얼로그 */} + <PriceAdjustmentDialog + open={isPriceAdjustmentDialogOpen} + onOpenChange={setIsPriceAdjustmentDialogOpen} + vendor={singleSelectedVendor || null} + onSuccess={onSuccess} + /> + </> ) } diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx new file mode 100644 index 00000000..96a3af0c --- /dev/null +++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx @@ -0,0 +1,195 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Switch } from "@/components/ui/switch" +import { useToast } from "@/hooks/use-toast" +import { updatePriceAdjustmentInfo } from "@/lib/bidding/detail/service" +import { QuotationVendor } from "@/lib/bidding/detail/service" +import { Loader2 } from "lucide-react" + +interface PriceAdjustmentDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendor: QuotationVendor | null + onSuccess: () => void +} + +export function PriceAdjustmentDialog({ + open, + onOpenChange, + vendor, + onSuccess, +}: PriceAdjustmentDialogProps) { + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // 폼 상태 + const [shiPriceAdjustmentApplied, setSHIPriceAdjustmentApplied] = React.useState<boolean | null>(null) + const [priceAdjustmentNote, setPriceAdjustmentNote] = React.useState("") + const [hasChemicalSubstance, setHasChemicalSubstance] = React.useState<boolean | null>(null) + + // 다이얼로그가 열릴 때 벤더 정보로 폼 초기화 + React.useEffect(() => { + if (open && vendor) { + setSHIPriceAdjustmentApplied(vendor.shiPriceAdjustmentApplied ?? null) + setPriceAdjustmentNote(vendor.priceAdjustmentNote || "") + setHasChemicalSubstance(vendor.hasChemicalSubstance ?? null) + } + }, [open, vendor]) + + const handleSubmit = async () => { + if (!vendor) return + + setIsSubmitting(true) + try { + const result = await updatePriceAdjustmentInfo({ + biddingCompanyId: vendor.id, + shiPriceAdjustmentApplied, + priceAdjustmentNote: priceAdjustmentNote || null, + hasChemicalSubstance, + }) + + if (result.success) { + toast({ + title: "저장 완료", + description: "연동제 정보가 저장되었습니다.", + }) + onOpenChange(false) + onSuccess() + } else { + toast({ + title: "오류", + description: result.error || "저장 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } catch (error) { + console.error("연동제 정보 저장 오류:", error) + toast({ + title: "오류", + description: "저장 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsSubmitting(false) + } + } + + if (!vendor) return null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>연동제 적용 설정</DialogTitle> + <DialogDescription> + <span className="font-semibold text-primary">{vendor.vendorName}</span> 업체의 연동제 적용 여부를 설정합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-6 py-4"> + {/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */} + {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4 bg-muted/50"> + <div className="space-y-0.5"> + <Label className="text-base">업체 연동제 요청</Label> + <p className="text-sm text-muted-foreground"> + 업체가 제출한 연동제 적용 요청 여부입니다. + </p> + </div> + <span className={`font-medium ${vendor.isPriceAdjustmentApplicableQuestion ? 'text-green-600' : 'text-gray-500'}`}> + {vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'} + </span> + </div> */} + + {/* SHI 연동제 적용여부 */} + <div className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <Label className="text-base">SHI 연동제 적용</Label> + <p className="text-sm text-muted-foreground"> + 해당 업체에 연동제를 적용할지 결정합니다. + </p> + </div> + <div className="flex items-center gap-3"> + <span className={`text-sm ${shiPriceAdjustmentApplied === false ? 'font-medium' : 'text-muted-foreground'}`}> + 미적용 + </span> + <Switch + checked={shiPriceAdjustmentApplied === true} + onCheckedChange={(checked) => setSHIPriceAdjustmentApplied(checked)} + /> + <span className={`text-sm ${shiPriceAdjustmentApplied === true ? 'font-medium' : 'text-muted-foreground'}`}> + 적용 + </span> + </div> + </div> + + {/* 연동제 Note */} + <div className="space-y-2"> + <Label htmlFor="price-adjustment-note">연동제 Note</Label> + <Textarea + id="price-adjustment-note" + placeholder="연동제 관련 추가 사항을 입력하세요" + value={priceAdjustmentNote} + onChange={(e) => setPriceAdjustmentNote(e.target.value)} + rows={4} + /> + </div> + + {/* 화학물질 여부 */} + {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <Label className="text-base">화학물질 해당여부</Label> + <p className="text-sm text-muted-foreground"> + 해당 업체가 화학물질 취급 대상인지 여부입니다. + </p> + </div> + <div className="flex items-center gap-3"> + <span className={`text-sm ${hasChemicalSubstance === false ? 'font-medium' : 'text-muted-foreground'}`}> + 해당없음 + </span> + <Switch + checked={hasChemicalSubstance === true} + onCheckedChange={(checked) => setHasChemicalSubstance(checked)} + /> + <span className={`text-sm ${hasChemicalSubstance === true ? 'font-medium text-red-600' : 'text-muted-foreground'}`}> + 해당 + </span> + </div> + </div> */} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button onClick={handleSubmit} disabled={isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 저장 중... + </> + ) : ( + "저장" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + diff --git a/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx new file mode 100644 index 00000000..f31caf5e --- /dev/null +++ b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx @@ -0,0 +1,324 @@ +'use client' + +import React from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { format } from 'date-fns' +import { ko } from 'date-fns/locale' +import { Loader2 } from 'lucide-react' + +interface PriceAdjustmentData { + id: number + itemName?: string | null + adjustmentReflectionPoint?: string | null + majorApplicableRawMaterial?: string | null + adjustmentFormula?: string | null + rawMaterialPriceIndex?: string | null + referenceDate?: Date | string | null + comparisonDate?: Date | string | null + adjustmentRatio?: string | null + notes?: string | null + adjustmentConditions?: string | null + majorNonApplicableRawMaterial?: string | null + adjustmentPeriod?: string | null + contractorWriter?: string | null + adjustmentDate?: Date | string | null + nonApplicableReason?: string | null + createdAt: Date | string + updatedAt: Date | string +} + +interface VendorPriceAdjustmentViewDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorName: string + priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부 + biddingCompanyId: number +} + +export function VendorPriceAdjustmentViewDialog({ + open, + onOpenChange, + vendorName, + priceAdjustmentResponse, + biddingCompanyId, +}: VendorPriceAdjustmentViewDialogProps) { + const [data, setData] = React.useState<PriceAdjustmentData | null>(null) + const [isLoading, setIsLoading] = React.useState(false) + const [error, setError] = React.useState<string | null>(null) + + // 다이얼로그가 열릴 때 데이터 로드 + React.useEffect(() => { + if (open && biddingCompanyId) { + loadPriceAdjustmentData() + } + }, [open, biddingCompanyId]) + + const loadPriceAdjustmentData = async () => { + setIsLoading(true) + setError(null) + try { + // 서버에서 연동제 폼 데이터 조회 + const { getPriceAdjustmentFormByBiddingCompanyId } = await import('@/lib/bidding/detail/service') + const formData = await getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId) + setData(formData) + } catch (err) { + console.error('Failed to load price adjustment data:', err) + setError('연동제 정보를 불러오는데 실패했습니다.') + } finally { + setIsLoading(false) + } + } + + // 날짜 포맷팅 헬퍼 + const formatDateValue = (date: Date | string | null | undefined) => { + if (!date) return '-' + try { + const dateObj = typeof date === 'string' ? new Date(date) : date + return format(dateObj, 'yyyy-MM-dd', { locale: ko }) + } catch { + return '-' + } + } + + // 연동제 적용 여부 판단 + const isApplied = priceAdjustmentResponse === true + const isNotApplied = priceAdjustmentResponse === false + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <span>하도급대금등 연동표</span> + <Badge variant="secondary">{vendorName}</Badge> + {isApplied && ( + <Badge variant="default" className="bg-green-600 hover:bg-green-700"> + 연동제 적용 + </Badge> + )} + {isNotApplied && ( + <Badge variant="outline" className="border-red-500 text-red-600"> + 연동제 미적용 + </Badge> + )} + {priceAdjustmentResponse === null && ( + <Badge variant="outline">해당없음</Badge> + )} + </DialogTitle> + <DialogDescription> + 협력업체가 제출한 연동제 적용 정보입니다. + {isApplied && " (연동제 적용)"} + {isNotApplied && " (연동제 미적용)"} + </DialogDescription> + </DialogHeader> + + {isLoading ? ( + <div className="flex items-center justify-center py-12"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + <span className="ml-2 text-muted-foreground">연동제 정보를 불러오는 중...</span> + </div> + ) : error ? ( + <div className="py-8 text-center text-red-600">{error}</div> + ) : !data && priceAdjustmentResponse !== null ? ( + <div className="py-8 text-center text-muted-foreground">연동제 상세 정보가 없습니다.</div> + ) : priceAdjustmentResponse === null ? ( + <div className="py-8 text-center text-muted-foreground">해당 업체는 연동제 관련 응답을 하지 않았습니다.</div> + ) : ( + <div className="space-y-6"> + {/* 기본 정보 */} + <div> + <h3 className="text-sm font-medium text-gray-900 mb-3">기본 정보</h3> + <div className="grid grid-cols-2 gap-4"> + <div> + <label className="text-xs text-gray-500">물품등의 명칭</label> + <p className="text-sm font-medium">{data?.itemName || '-'}</p> + </div> + <div> + <label className="text-xs text-gray-500">연동제 적용 여부</label> + <div className="mt-1"> + {isApplied && ( + <Badge variant="default" className="bg-green-600 hover:bg-green-700"> + 예 (연동제 적용) + </Badge> + )} + {isNotApplied && ( + <Badge variant="outline" className="border-red-500 text-red-600"> + 아니오 (연동제 미적용) + </Badge> + )} + </div> + </div> + {isApplied && ( + <div> + <label className="text-xs text-gray-500">조정대금 반영시점</label> + <p className="text-sm font-medium">{data?.adjustmentReflectionPoint || '-'}</p> + </div> + )} + </div> + </div> + + <Separator /> + + {/* 원재료 정보 */} + <div> + <h3 className="text-sm font-medium text-gray-900 mb-3">원재료 정보</h3> + <div className="space-y-4"> + {isApplied && ( + <div> + <label className="text-xs text-gray-500">연동대상 주요 원재료</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data?.majorApplicableRawMaterial || '-'} + </p> + </div> + )} + {isNotApplied && ( + <> + <div> + <label className="text-xs text-gray-500">연동 미적용 주요 원재료</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data?.majorNonApplicableRawMaterial || '-'} + </p> + </div> + <div> + <label className="text-xs text-gray-500">연동 미적용 사유</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data?.nonApplicableReason || '-'} + </p> + </div> + </> + )} + </div> + </div> + + {isApplied && data && ( + <> + <Separator /> + + {/* 연동 공식 및 지표 */} + <div> + <h3 className="text-sm font-medium text-gray-900 mb-3">연동 공식 및 지표</h3> + <div className="space-y-4"> + <div> + <label className="text-xs text-gray-500">하도급대금등 연동 산식</label> + <div className="p-3 bg-gray-50 rounded-md"> + <p className="text-sm font-mono whitespace-pre-wrap"> + {data.adjustmentFormula || '-'} + </p> + </div> + </div> + <div> + <label className="text-xs text-gray-500">원재료 가격 기준지표</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data.rawMaterialPriceIndex || '-'} + </p> + </div> + <div className="grid grid-cols-2 gap-4"> + <div> + <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 기준시점</label> + <p className="text-sm font-medium">{data.referenceDate ? formatDateValue(data.referenceDate) : '-'}</p> + </div> + <div> + <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 비교시점</label> + <p className="text-sm font-medium">{data.comparisonDate ? formatDateValue(data.comparisonDate) : '-'}</p> + </div> + </div> + {data.adjustmentRatio && ( + <div> + <label className="text-xs text-gray-500">반영비율</label> + <p className="text-sm font-medium"> + {data.adjustmentRatio}% + </p> + </div> + )} + </div> + </div> + + <Separator /> + + {/* 조정 조건 및 기타 */} + <div> + <h3 className="text-sm font-medium text-gray-900 mb-3">조정 조건 및 기타</h3> + <div className="space-y-4"> + <div> + <label className="text-xs text-gray-500">조정요건</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data.adjustmentConditions || '-'} + </p> + </div> + <div className="grid grid-cols-2 gap-4"> + <div> + <label className="text-xs text-gray-500">조정주기</label> + <p className="text-sm font-medium">{data.adjustmentPeriod || '-'}</p> + </div> + <div> + <label className="text-xs text-gray-500">조정일</label> + <p className="text-sm font-medium">{data.adjustmentDate ? formatDateValue(data.adjustmentDate) : '-'}</p> + </div> + </div> + <div> + <label className="text-xs text-gray-500">수탁기업(협력사)작성자</label> + <p className="text-sm font-medium">{data.contractorWriter || '-'}</p> + </div> + {data.notes && ( + <div> + <label className="text-xs text-gray-500">기타사항</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data.notes} + </p> + </div> + )} + </div> + </div> + </> + )} + + {isNotApplied && data && ( + <> + <Separator /> + <div> + <h3 className="text-sm font-medium text-gray-900 mb-3">작성자 정보</h3> + <div> + <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label> + <p className="text-sm font-medium">{data.contractorWriter || '-'}</p> + </div> + </div> + </> + )} + + {data && ( + <> + <Separator /> + + {/* 메타 정보 */} + <div className="text-xs text-gray-500 space-y-1"> + <p>작성일: {formatDateValue(data.createdAt)}</p> + <p>수정일: {formatDateValue(data.updatedAt)}</p> + </div> + </> + )} + + <Separator /> + + {/* 참고 경고문 */} + <div className="text-xs text-red-600 space-y-2 bg-red-50 p-3 rounded-md border border-red-200"> + <p className="font-medium">※ 참고사항</p> + <div className="space-y-1"> + <p>• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.</p> + <p>• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.</p> + </div> + </div> + </div> + )} + </DialogContent> + </Dialog> + ) +} + diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts index 11955a39..b422118d 100644 --- a/lib/bidding/handlers.ts +++ b/lib/bidding/handlers.ts @@ -10,6 +10,96 @@ import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; /** + * 결재 완료 시점을 기준으로 입찰서 제출기간 계산 및 업데이트 + * + * 계산 로직: + * - baseDate = 결재완료일 날짜만 (00:00:00) + * - 시작일 = baseDate + submissionStartOffset일 + submissionStartDate의 시:분 + * - 마감일 = 시작일(날짜만) + submissionDurationDays일 + submissionEndDate의 시:분 + */ +async function calculateAndUpdateSubmissionDates(biddingId: number) { + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + // 현재 입찰 정보 조회 + const biddingInfo = await db + .select({ + submissionStartOffset: biddings.submissionStartOffset, + submissionDurationDays: biddings.submissionDurationDays, + submissionStartDate: biddings.submissionStartDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00) + submissionEndDate: biddings.submissionEndDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00) + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[calculateAndUpdateSubmissionDates] 입찰 정보를 찾을 수 없음', { biddingId }); + throw new Error('입찰 정보를 찾을 수 없습니다.'); + } + + const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = biddingInfo[0]; + + // 필수 값 검증 + if (submissionStartOffset === null || submissionDurationDays === null) { + debugError('[calculateAndUpdateSubmissionDates] 오프셋 값이 설정되지 않음', { submissionStartOffset, submissionDurationDays }); + throw new Error('입찰서 제출기간 오프셋이 설정되지 않았습니다.'); + } + + // 시간 추출 (기본값: 시작 09:00, 마감 18:00) + const startTime = submissionStartDate + ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() } + : { hours: 9, minutes: 0 }; + const endTime = submissionEndDate + ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() } + : { hours: 18, minutes: 0 }; + + // 1. baseDate = 결재완료일 날짜만 (KST 기준 00:00:00) + const now = new Date(); + const baseDate = new Date(now); + // KST 기준으로 날짜만 추출 (시간은 00:00:00) + baseDate.setHours(0, 0, 0, 0); + + // 2. 시작일 = baseDate + offset일 + 시작시간 + const calculatedStartDate = new Date(baseDate); + calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset); + calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0); + + // 3. 마감일 = 시작일(날짜만) + duration일 + 마감시간 + const calculatedEndDate = new Date(calculatedStartDate); + calculatedEndDate.setHours(0, 0, 0, 0); // 시작일의 날짜만 + calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays); + calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0); + + debugLog('[calculateAndUpdateSubmissionDates] 입찰서 제출기간 계산 완료', { + biddingId, + baseDate: baseDate.toISOString(), + submissionStartOffset, + submissionDurationDays, + startTime, + endTime, + calculatedStartDate: calculatedStartDate.toISOString(), + calculatedEndDate: calculatedEndDate.toISOString(), + }); + + // DB 업데이트 + await db + .update(biddings) + .set({ + submissionStartDate: calculatedStartDate, + submissionEndDate: calculatedEndDate, + updatedAt: new Date(), + }) + .where(eq(biddings.id, biddingId)); + + return { + startDate: calculatedStartDate, + endDate: calculatedEndDate, + }; +} + +/** * 입찰초대 핸들러 (결재 승인 후 실행됨) * * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지) @@ -52,7 +142,7 @@ export async function requestBiddingInvitationInternal(payload: { try { // 1. 기본계약 발송 const { sendBiddingBasicContracts } = await import('@/lib/bidding/pre-quote/service'); - + const vendorDataForContract = payload.vendors.map(vendor => ({ vendorId: vendor.vendorId, vendorName: vendor.vendorName, @@ -86,7 +176,7 @@ export async function requestBiddingInvitationInternal(payload: { debugLog('[BiddingInvitationHandler] 기본계약 발송 완료'); - // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경) + // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경, 입찰서 제출기간 자동 계산) const { registerBidding } = await import('@/lib/bidding/detail/service'); const registerResult = await registerBidding(payload.biddingId, payload.currentUserId.toString()); @@ -127,6 +217,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { biddingNumber: string; projectName?: string; itemName?: string; + awardCount: string; biddingType: string; bidPicName?: string; supplyPicName?: string; @@ -181,7 +272,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 +281,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const biddingNumber = bidding.biddingNumber || ''; // 낙찰업체수 - const winnerCount = '1'; // 기본값, 실제로는 bidding 설정에서 가져와야 함 + const awardCount = bidding.awardCount || ''; // 계약구분 const contractType = bidding.biddingType || ''; @@ -199,7 +290,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() : ''; @@ -219,9 +310,6 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { // 입찰 공고문 const biddingNotice = message || ''; - // 입찰담당자 (중복이지만 템플릿에 맞춤) - const biddingManagerDup = bidding.bidPicName || bidding.supplyPicName || ''; - // 협력사 정보들 const vendorVariables: Record<string, string> = {}; vendors.forEach((vendor, index) => { @@ -237,8 +325,6 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const hasSpecMeeting = bidding.hasSpecificationMeeting ? '예' : '아니오'; const specMeetingStart = bidding.submissionStartDate ? new Date(bidding.submissionStartDate).toISOString().slice(0, 16).replace('T', ' ') : ''; const specMeetingEnd = bidding.submissionEndDate ? new Date(bidding.submissionEndDate).toISOString().slice(0, 16).replace('T', ' ') : ''; - const specMeetingStartDup = specMeetingStart; - const specMeetingEndDup = specMeetingEnd; // 입찰서제출기간 정보 const submissionPeriodExecution = '예'; // 입찰 기간이 있으므로 예 @@ -272,7 +358,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { 제목: title, 입찰명: biddingTitle, 입찰번호: biddingNumber, - 낙찰업체수: winnerCount, + 낙찰업체수: awardCount, 계약구분: contractType, 'P/R번호': prNumber, 예산: budget, @@ -426,12 +512,13 @@ export async function requestBiddingClosureInternal(payload: { const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); - + const { getUserNameById } = await import('@/lib/bidding/actions'); + const userName = await getUserNameById(payload.currentUserId.toString()); await db .update(biddings) .set({ status: 'bid_closure', - updatedBy: payload.currentUserId.toString(), + updatedBy: userName, updatedAt: new Date(), remarks: payload.description, // 폐찰 사유를 remarks에 저장 }) @@ -618,6 +705,15 @@ export async function mapBiddingAwardToTemplateVariables(payload: { biddingId: number; selectionReason: string; requestedAt: Date; + awardedCompanies?: Array<{ + companyId: number; + companyName: string | null; + finalQuoteAmount: number; + awardRatio: number; + vendorCode?: string | null; + companySize?: string | null; + targetPrice?: number | null; + }>; }): Promise<Record<string, string>> { const { biddingId, selectionReason, requestedAt } = payload; @@ -637,6 +733,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: { biddingType: biddings.biddingType, bidPicName: biddings.bidPicName, supplyPicName: biddings.supplyPicName, + budget: biddings.budget, targetPrice: biddings.targetPrice, awardCount: biddings.awardCount, }) @@ -652,8 +749,11 @@ export async function mapBiddingAwardToTemplateVariables(payload: { const bidding = biddingInfo[0]; // 2. 낙찰된 업체 정보 조회 - const { getAwardedCompanies } = await import('@/lib/bidding/detail/service'); - const awardedCompanies = await getAwardedCompanies(biddingId); + let awardedCompanies = payload.awardedCompanies; + if (!awardedCompanies) { + const { getAwardedCompanies } = await import('@/lib/bidding/detail/service'); + awardedCompanies = await getAwardedCompanies(biddingId); + } // 3. 입찰 대상 자재 정보 조회 const biddingItemsInfo = await db @@ -684,7 +784,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-page-header.tsx b/lib/bidding/list/biddings-page-header.tsx index 0be2172b..227a917b 100644 --- a/lib/bidding/list/biddings-page-header.tsx +++ b/lib/bidding/list/biddings-page-header.tsx @@ -4,7 +4,11 @@ import { Button } from "@/components/ui/button" import { Plus, FileText, TrendingUp } from "lucide-react" import { useRouter } from "next/navigation" import { InformationButton } from "@/components/information/information-button" -export function BiddingsPageHeader() { +import { useTranslation } from "@/i18n/client" + +export function BiddingsPageHeader(props: {lng: string}) { + const {lng} = props + const {t} = useTranslation(lng, 'menu') const router = useRouter() return ( @@ -12,11 +16,11 @@ export function BiddingsPageHeader() { {/* 좌측: 제목과 설명 */} <div className="space-y-1"> <div className="flex items-center gap-2"> - <h2 className="text-3xl font-bold tracking-tight">입찰 목록 관리</h2> + <h2 className="text-3xl font-bold tracking-tight">{t('menu.procurement.bid_management')}</h2> <InformationButton pagePath="evcp/bid" /> </div> <p className="text-muted-foreground"> - 입찰 공고를 생성하고 진행 상황을 관리할 수 있습니다. + {t('menu.procurement.bid_management_desc')} </p> </div> diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx index 62d4dbe7..602bcbb9 100644 --- a/lib/bidding/list/biddings-table-columns.tsx +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -257,21 +257,40 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef id: "submissionPeriod", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />, cell: ({ row }) => { + const status = row.original.status + + // 입찰생성 또는 결재진행중 상태일 때는 특별 메시지 표시 + if (status === 'bidding_generated') { + return ( + <div className="text-xs text-orange-600 font-medium"> + 입찰 등록중입니다 + </div> + ) + } + + if (status === 'approval_pending') { + return ( + <div className="text-xs text-blue-600 font-medium"> + 결재 진행중입니다 + </div> + ) + } + const startDate = row.original.submissionStartDate const endDate = row.original.submissionEndDate - + if (!startDate || !endDate) return <span className="text-muted-foreground">-</span> - + const startObj = new Date(startDate) const endObj = new Date(endDate) - // UI 표시용 KST 변환 - const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') - + // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') + return ( <div className="text-xs"> <div> - {formatKst(startObj)} ~ {formatKst(endObj)} + {formatValue(startObj)} ~ {formatValue(endObj)} </div> </div> ) 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..64d98399 --- /dev/null +++ b/lib/bidding/list/export-biddings-to-excel.ts @@ -0,0 +1,209 @@ +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) + + // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') + + value = `${formatValue(startObj)} ~ ${formatValue(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..fe5b17a9 --- /dev/null +++ b/lib/bidding/manage/import-bidding-items-from-excel.ts @@ -0,0 +1,273 @@ +import ExcelJS from "exceljs" +import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor" +import { getProjectIdByCodeAndName } from "./project-utils" +import { decryptWithServerAction } from "@/components/drm/drmUtils" + +export interface ImportBiddingItemsResult { + success: boolean + 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() + // DRM 해제 후 ArrayBuffer 획득 (DRM 서버 미연결 시 원본 반환) + const arrayBuffer = await decryptWithServerAction(file) + 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/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 08cb0e2c..6fef228c 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -49,16 +49,6 @@ interface UpdateBiddingCompanyInput { isAttendingMeeting?: boolean
}
-interface PrItemQuotation {
- prItemId: number
- bidUnitPrice: number
- bidAmount: number
- proposedDeliveryDate?: string
- technicalSpecification?: string
-}
-
-
-
// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
try {
@@ -201,16 +191,6 @@ export async function deleteBiddingCompany(id: number) { }
-// 선택된 업체들에게 사전견적 초대 발송
-interface CompanyWithContacts {
- id: number
- companyId: number
- companyName: string
- selectedMainEmail: string
- additionalEmails: string[]
-}
-
-
// PR 아이템 조회 (입찰에 포함된 품목들)
export async function getPrItemsForBidding(biddingId: number, companyId?: number) {
try {
@@ -253,12 +233,11 @@ export async function getPrItemsForBidding(biddingId: number, companyId?: number selectFields.bidAmount = companyPrItemBids.bidAmount
selectFields.proposedDeliveryDate = companyPrItemBids.proposedDeliveryDate
selectFields.technicalSpecification = companyPrItemBids.technicalSpecification
- }
-
- let query = db.select(selectFields).from(prItemsForBidding)
+ selectFields.currency = companyPrItemBids.currency
- if (companyId) {
- query = query
+ return await db
+ .select(selectFields)
+ .from(prItemsForBidding)
.leftJoin(biddingCompanies, and(
eq(biddingCompanies.biddingId, biddingId),
eq(biddingCompanies.companyId, companyId)
@@ -266,13 +245,16 @@ export async function getPrItemsForBidding(biddingId: number, companyId?: number .leftJoin(companyPrItemBids, and(
eq(companyPrItemBids.prItemId, prItemsForBidding.id),
eq(companyPrItemBids.biddingCompanyId, biddingCompanies.id)
- )) as any
+ ))
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
+ } else {
+ return await db
+ .select(selectFields)
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
}
-
- query = query.where(eq(prItemsForBidding.biddingId, biddingId)).orderBy(prItemsForBidding.id) as any
-
- const prItems = await query
- return prItems
} catch (error) {
console.error('Failed to get PR items for bidding:', error)
return []
@@ -877,8 +859,8 @@ export async function getSelectedVendorsForBidding(biddingId: number) { interface CreatePreQuoteRfqInput {
rfqType: string;
rfqTitle: string;
- dueDate: Date;
- picUserId: number;
+ dueDate?: Date;
+ picUserId: number | string | undefined;
projectId?: number;
remark?: string;
biddingNumber?: string;
@@ -893,6 +875,8 @@ interface CreatePreQuoteRfqInput { remark?: string;
materialCode?: string;
materialName?: string;
+ totalWeight?: number | string | null; // 중량 추가
+ weightUnit?: string | null; // 중량단위 추가
}>;
biddingConditions?: {
paymentTerms?: string | null
@@ -994,6 +978,10 @@ export async function createPreQuoteRfqAction(input: CreatePreQuoteRfqInput) { quantity: item.quantity, // 수량
uom: item.uom, // 단위
+ // 중량 정보
+ grossWeight: item.totalWeight ? (typeof item.totalWeight === 'string' ? parseFloat(item.totalWeight) : item.totalWeight) : null,
+ gwUom: item.weightUnit || null,
+
majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정
remark: item.remark || null, // 비고
}));
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx index 9650574a..6847d9d5 100644 --- a/lib/bidding/receive/biddings-receive-columns.tsx +++ b/lib/bidding/receive/biddings-receive-columns.tsx @@ -1,404 +1,404 @@ -"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
-} from "lucide-react"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-import { formatDate } from "@/lib/utils"
-import { DataTableRowAction } from "@/types/table"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
- onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
-}
-
-// 상태별 배지 색상
-const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case 'received_quotation':
- return 'secondary'
- case 'bidding_opened':
- return 'default'
- case 'bidding_closed':
- return 'outline'
- default:
- return 'outline'
- }
-}
-
-// 금액 포맷팅
-const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
- if (!amount) return '-'
-
- const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
- if (isNaN(numAmount)) return '-'
-
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: currency,
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(numAmount)
-}
-
-export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
-
- return [
- // ░░░ 선택 ░░░
- {
- id: "select",
- header: "",
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => {
- // single select 모드에서는 다른 행들의 선택을 해제
- row.toggleSelected(!!value)
- }}
- aria-label="행 선택"
- />
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
-
- // ░░░ 입찰번호 ░░░
- {
- accessorKey: "biddingNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
- cell: ({ row }) => (
- <div className="font-mono text-sm">
- {row.original.biddingNumber}
- </div>
- ),
- size: 120,
- meta: { excelHeader: "입찰번호" },
- },
-
- // ░░░ 입찰명 ░░░
- {
- accessorKey: "title",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[200px]" title={row.original.title}>
- {/* <Button
- variant="link"
- className="p-0 h-auto text-left justify-start font-bold underline"
- onClick={() => setRowAction({ row, type: "view" })}
- >
- <div className="whitespace-pre-line">
- {row.original.title}
- </div>
- </Button> */}
- {row.original.title}
- </div>
- ),
- size: 200,
- meta: { excelHeader: "입찰명" },
- },
-
- // ░░░ 원입찰번호 ░░░
- {
- accessorKey: "originalBiddingNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
- cell: ({ row }) => (
- <div className="font-mono text-sm">
- {row.original.originalBiddingNumber || '-'}
- </div>
- ),
- size: 120,
- meta: { excelHeader: "원입찰번호" },
- },
-
- // ░░░ 진행상태 ░░░
- {
- accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
- cell: ({ row }) => (
- <Badge variant={getStatusBadgeVariant(row.original.status)}>
- {biddingStatusLabels[row.original.status]}
- </Badge>
- ),
- size: 120,
- meta: { excelHeader: "진행상태" },
- },
-
- // ░░░ 계약구분 ░░░
- {
- accessorKey: "contractType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
- cell: ({ row }) => (
- <Badge variant="outline">
- {contractTypeLabels[row.original.contractType]}
- </Badge>
- ),
- size: 100,
- meta: { excelHeader: "계약구분" },
- },
-
- // ░░░ 입찰서제출기간 ░░░
- {
- id: "submissionPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
- cell: ({ row }) => {
- const startDate = row.original.submissionStartDate
- const endDate = row.original.submissionEndDate
-
- if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
-
- const startObj = new Date(startDate)
- const endObj = new Date(endDate)
-
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
-
- return (
- <div className="text-xs">
- <div>
- {formatKst(startObj)} ~ {formatKst(endObj)}
- </div>
- </div>
- )
- },
- size: 140,
- meta: { excelHeader: "입찰서제출기간" },
- },
-
- // ░░░ P/R번호 ░░░
- {
- accessorKey: "prNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "P/R번호" },
- },
-
- // ░░░ 입찰담당자 ░░░
- {
- accessorKey: "bidPicName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
- cell: ({ row }) => {
- const bidPic = row.original.bidPicName
- const supplyPic = row.original.supplyPicName
-
- const displayName = bidPic || supplyPic || "-"
- return <span className="text-sm">{displayName}</span>
- },
- size: 100,
- meta: { excelHeader: "입찰담당자" },
- },
-
- // ░░░ 참여예정협력사 ░░░
- {
- id: "participantExpected",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-blue-50"
- onClick={() => onParticipantClick?.(row.original.id, 'expected')}
- disabled={row.original.participantExpected === 0}
- >
- <div className="flex items-center gap-1">
- <Users className="h-4 w-4 text-blue-500" />
- <span className="text-sm font-medium">{row.original.participantExpected}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "참여예정협력사" },
- },
-
- // ░░░ 참여협력사 ░░░
- {
- id: "participantParticipated",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-green-50"
- onClick={() => onParticipantClick?.(row.original.id, 'participated')}
- disabled={row.original.participantParticipated === 0}
- >
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-500" />
- <span className="text-sm font-medium">{row.original.participantParticipated}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "참여협력사" },
- },
-
- // ░░░ 포기협력사 ░░░
- {
- id: "participantDeclined",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-red-50"
- onClick={() => onParticipantClick?.(row.original.id, 'declined')}
- disabled={row.original.participantDeclined === 0}
- >
- <div className="flex items-center gap-1">
- <XCircle className="h-4 w-4 text-red-500" />
- <span className="text-sm font-medium">{row.original.participantDeclined}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "포기협력사" },
- },
-
- // ░░░ 미제출협력사 ░░░
- {
- id: "participantPending",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-yellow-50"
- onClick={() => onParticipantClick?.(row.original.id, 'pending')}
- disabled={row.original.participantPending === 0}
- >
- <div className="flex items-center gap-1">
- <Clock className="h-4 w-4 text-yellow-500" />
- <span className="text-sm font-medium">{row.original.participantPending}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "미제출협력사" },
- },
-
- // ░░░ 개찰자명 ░░░
- {
- id: "openedBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />,
- cell: ({ row }) => {
- const openedBy = row.original.openedBy
- return <span className="text-sm">{openedBy || '-'}</span>
- },
- size: 100,
- meta: { excelHeader: "개찰자명" },
- },
-
- // ░░░ 개찰일 ░░░
- {
- id: "openedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />,
- cell: ({ row }) => {
- const openedAt = row.original.openedAt
- return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span>
- },
- size: 100,
- meta: { excelHeader: "개찰일" },
- },
-
- // ░░░ 등록자 ░░░
- {
- accessorKey: "createdBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.createdBy || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "등록자" },
- },
-
- // ░░░ 등록일시 ░░░
- {
- accessorKey: "createdAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "등록일시" },
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 액션
- // ═══════════════════════════════════════════════════════════════
- // {
- // id: "actions",
- // header: "액션",
- // cell: ({ row }) => (
- // <DropdownMenu>
- // <DropdownMenuTrigger asChild>
- // <Button variant="ghost" className="h-8 w-8 p-0">
- // <span className="sr-only">메뉴 열기</span>
- // <AlertTriangle className="h-4 w-4" />
- // </Button>
- // </DropdownMenuTrigger>
- // <DropdownMenuContent align="end">
- // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
- // <Eye className="mr-2 h-4 w-4" />
- // 상세보기
- // </DropdownMenuItem>
- // </DropdownMenuContent>
- // </DropdownMenu>
- // ),
- // size: 50,
- // enableSorting: false,
- // enableHiding: false,
- // },
- ]
-}
+"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle +} from "lucide-react" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { + biddingStatusLabels, + contractTypeLabels, +} from "@/db/schema" +import { formatDate } from "@/lib/utils" +import { DataTableRowAction } from "@/types/table" + +type BiddingReceiveItem = { + id: number + biddingNumber: string + originalBiddingNumber: string | null + title: string + status: string + contractType: string + prNumber: string | null + submissionStartDate: Date | null + submissionEndDate: Date | null + bidPicName: string | null + supplyPicName: string | null + createdBy: string | null + createdAt: Date | null + updatedAt: Date | null + + // 참여 현황 + participantExpected: number + participantParticipated: number + participantDeclined: number + participantPending: number + + // 개찰 정보 + openedAt: Date | null + openedBy: string | null +} + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>> + onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void +} + +// 상태별 배지 색상 +const getStatusBadgeVariant = (status: string) => { + switch (status) { + case 'received_quotation': + return 'secondary' + case 'bidding_opened': + return 'default' + case 'bidding_closed': + return 'outline' + default: + return 'outline' + } +} + +// 금액 포맷팅 +const formatCurrency = (amount: string | number | null, currency = 'KRW') => { + if (!amount) return '-' + + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount + if (isNaN(numAmount)) return '-' + + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(numAmount) +} + +export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] { + + return [ + // ░░░ 선택 ░░░ + { + id: "select", + header: "", + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => { + // single select 모드에서는 다른 행들의 선택을 해제 + row.toggleSelected(!!value) + }} + aria-label="행 선택" + /> + ), + size: 50, + enableSorting: false, + enableHiding: false, + }, + + // ░░░ 입찰번호 ░░░ + { + accessorKey: "biddingNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />, + cell: ({ row }) => ( + <div className="font-mono text-sm"> + {row.original.biddingNumber} + </div> + ), + size: 120, + meta: { excelHeader: "입찰번호" }, + }, + + // ░░░ 입찰명 ░░░ + { + accessorKey: "title", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />, + cell: ({ row }) => ( + <div className="truncate max-w-[200px]" title={row.original.title}> + {/* <Button + variant="link" + className="p-0 h-auto text-left justify-start font-bold underline" + onClick={() => setRowAction({ row, type: "view" })} + > + <div className="whitespace-pre-line"> + {row.original.title} + </div> + </Button> */} + {row.original.title} + </div> + ), + size: 200, + meta: { excelHeader: "입찰명" }, + }, + + // ░░░ 원입찰번호 ░░░ + { + accessorKey: "originalBiddingNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />, + cell: ({ row }) => ( + <div className="font-mono text-sm"> + {row.original.originalBiddingNumber || '-'} + </div> + ), + size: 120, + meta: { excelHeader: "원입찰번호" }, + }, + + // ░░░ 진행상태 ░░░ + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />, + cell: ({ row }) => ( + <Badge variant={getStatusBadgeVariant(row.original.status)}> + {biddingStatusLabels[row.original.status]} + </Badge> + ), + size: 120, + meta: { excelHeader: "진행상태" }, + }, + + // ░░░ 계약구분 ░░░ + { + accessorKey: "contractType", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />, + cell: ({ row }) => ( + <Badge variant="outline"> + {contractTypeLabels[row.original.contractType]} + </Badge> + ), + size: 100, + meta: { excelHeader: "계약구분" }, + }, + + // ░░░ 입찰서제출기간 ░░░ + { + id: "submissionPeriod", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />, + cell: ({ row }) => { + const startDate = row.original.submissionStartDate + const endDate = row.original.submissionEndDate + + if (!startDate || !endDate) return <span className="text-muted-foreground">-</span> + + const startObj = new Date(startDate) + const endObj = new Date(endDate) + + // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') + + return ( + <div className="text-xs"> + <div> + {formatValue(startObj)} ~ {formatValue(endObj)} + </div> + </div> + ) + }, + size: 140, + meta: { excelHeader: "입찰서제출기간" }, + }, + + // ░░░ P/R번호 ░░░ + { + accessorKey: "prNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.prNumber || '-'}</span> + ), + size: 100, + meta: { excelHeader: "P/R번호" }, + }, + + // ░░░ 입찰담당자 ░░░ + { + accessorKey: "bidPicName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />, + cell: ({ row }) => { + const bidPic = row.original.bidPicName + const supplyPic = row.original.supplyPicName + + const displayName = bidPic || supplyPic || "-" + return <span className="text-sm">{displayName}</span> + }, + size: 100, + meta: { excelHeader: "입찰담당자" }, + }, + + // ░░░ 참여예정협력사 ░░░ + { + id: "participantExpected", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="h-auto p-1 hover:bg-blue-50" + onClick={() => onParticipantClick?.(row.original.id, 'expected')} + disabled={row.original.participantExpected === 0} + > + <div className="flex items-center gap-1"> + <Users className="h-4 w-4 text-blue-500" /> + <span className="text-sm font-medium">{row.original.participantExpected}</span> + </div> + </Button> + ), + size: 100, + meta: { excelHeader: "참여예정협력사" }, + }, + + // ░░░ 참여협력사 ░░░ + { + id: "participantParticipated", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="h-auto p-1 hover:bg-green-50" + onClick={() => onParticipantClick?.(row.original.id, 'participated')} + disabled={row.original.participantParticipated === 0} + > + <div className="flex items-center gap-1"> + <CheckCircle className="h-4 w-4 text-green-500" /> + <span className="text-sm font-medium">{row.original.participantParticipated}</span> + </div> + </Button> + ), + size: 100, + meta: { excelHeader: "참여협력사" }, + }, + + // ░░░ 포기협력사 ░░░ + { + id: "participantDeclined", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="h-auto p-1 hover:bg-red-50" + onClick={() => onParticipantClick?.(row.original.id, 'declined')} + disabled={row.original.participantDeclined === 0} + > + <div className="flex items-center gap-1"> + <XCircle className="h-4 w-4 text-red-500" /> + <span className="text-sm font-medium">{row.original.participantDeclined}</span> + </div> + </Button> + ), + size: 100, + meta: { excelHeader: "포기협력사" }, + }, + + // ░░░ 미제출협력사 ░░░ + { + id: "participantPending", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="h-auto p-1 hover:bg-yellow-50" + onClick={() => onParticipantClick?.(row.original.id, 'pending')} + disabled={row.original.participantPending === 0} + > + <div className="flex items-center gap-1"> + <Clock className="h-4 w-4 text-yellow-500" /> + <span className="text-sm font-medium">{row.original.participantPending}</span> + </div> + </Button> + ), + size: 100, + meta: { excelHeader: "미제출협력사" }, + }, + + // ░░░ 개찰자명 ░░░ + { + id: "openedBy", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />, + cell: ({ row }) => { + const openedBy = row.original.openedBy + return <span className="text-sm">{openedBy || '-'}</span> + }, + size: 100, + meta: { excelHeader: "개찰자명" }, + }, + + // ░░░ 개찰일 ░░░ + { + id: "openedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />, + cell: ({ row }) => { + const openedAt = row.original.openedAt + return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span> + }, + size: 100, + meta: { excelHeader: "개찰일" }, + }, + + // ░░░ 등록자 ░░░ + { + accessorKey: "createdBy", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />, + cell: ({ row }) => ( + <span className="text-sm">{row.original.createdBy || '-'}</span> + ), + size: 100, + meta: { excelHeader: "등록자" }, + }, + + // ░░░ 등록일시 ░░░ + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />, + cell: ({ row }) => ( + <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span> + ), + size: 100, + meta: { excelHeader: "등록일시" }, + }, + + // ═══════════════════════════════════════════════════════════════ + // 액션 + // ═══════════════════════════════════════════════════════════════ + // { + // id: "actions", + // header: "액션", + // cell: ({ row }) => ( + // <DropdownMenu> + // <DropdownMenuTrigger asChild> + // <Button variant="ghost" className="h-8 w-8 p-0"> + // <span className="sr-only">메뉴 열기</span> + // <AlertTriangle className="h-4 w-4" /> + // </Button> + // </DropdownMenuTrigger> + // <DropdownMenuContent align="end"> + // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}> + // <Eye className="mr-2 h-4 w-4" /> + // 상세보기 + // </DropdownMenuItem> + // </DropdownMenuContent> + // </DropdownMenu> + // ), + // size: 50, + // enableSorting: false, + // enableHiding: false, + // }, + ] +} diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx index 2b141d5e..6a48fa79 100644 --- a/lib/bidding/receive/biddings-receive-table.tsx +++ b/lib/bidding/receive/biddings-receive-table.tsx @@ -1,296 +1,297 @@ -"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import { Button } from "@/components/ui/button"
-import { Loader2 } from "lucide-react"
-import { toast } from "sonner"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getBiddingsReceiveColumns } from "./biddings-receive-columns"
-import { getBiddingsForReceive } from "@/lib/bidding/service"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
-import { openBiddingAction } from "@/lib/bidding/actions"
-import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
-import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
- participantFinalSubmitted: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface BiddingsReceiveTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getBiddingsForReceive>>
- ]
- >
-}
-
-export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
- const [biddingsResult] = React.use(promises)
-
- // biddingsResult에서 data와 pageCount 추출
- const { data, pageCount } = biddingsResult
-
- const [isCompact, setIsCompact] = React.useState<boolean>(false)
- // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
- // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
- const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
- const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
-
- // 협력사 다이얼로그 관련 상태
- const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
- const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
- const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null)
- const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([])
- const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
-
- const router = useRouter()
- const { data: session } = useSession()
-
- // 협력사 클릭 핸들러
- const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
- setSelectedBiddingId(biddingId)
- setSelectedParticipantType(participantType)
- setIsLoadingParticipants(true)
- setParticipantsDialogOpen(true)
-
- try {
- // 협력사 데이터 로드 (모든 초대된 협력사)
- const companies = await getAllBiddingCompanies(biddingId)
-
- console.log('Loaded companies:', companies)
-
- // 필터링 없이 모든 데이터 그대로 표시
- // invitationStatus가 그대로 다이얼로그에 표시됨
- setParticipantCompanies(companies)
- } catch (error) {
- console.error('Failed to load participant companies:', error)
- toast.error('협력사 목록을 불러오는데 실패했습니다.')
- setParticipantCompanies([])
- } finally {
- setIsLoadingParticipants(false)
- }
- }, [])
-
- const columns = React.useMemo(
- () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
- [setRowAction, handleParticipantClick]
- )
-
- // rowAction 변경 감지하여 해당 다이얼로그 열기
- React.useEffect(() => {
- if (rowAction) {
- setSelectedBidding(rowAction.row.original)
-
- switch (rowAction.type) {
- case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
- break
- default:
- break
- }
- }
- }, [rowAction, router])
-
- const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
- {
- id: "biddingNumber",
- label: "입찰번호",
- placeholder: "입찰번호를 입력하세요",
- },
- {
- id: "prNumber",
- label: "P/R번호",
- placeholder: "P/R번호를 입력하세요",
- },
- {
- id: "title",
- label: "입찰명",
- placeholder: "입찰명을 입력하세요",
- },
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<BiddingReceiveItem>[] = [
- { id: "title", label: "입찰명", type: "text" },
- { id: "biddingNumber", label: "입찰번호", type: "text" },
- { id: "bidPicName", label: "입찰담당자", type: "text" },
- {
- id: "status",
- label: "진행상태",
- type: "multi-select",
- options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- {
- id: "contractType",
- label: "계약구분",
- type: "select",
- options: Object.entries(contractTypeLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- { id: "createdAt", label: "등록일", type: "date" },
- { id: "submissionStartDate", label: "제출시작일", type: "date" },
- { id: "submissionEndDate", label: "제출마감일", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- enableRowSelection: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- const handleCompactChange = React.useCallback((compact: boolean) => {
- setIsCompact(compact)
- }, [])
-
-
- // 선택된 행 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
-
- // 개찰 가능 여부 확인
- const canOpen = React.useMemo(() => {
- if (!selectedBiddingForAction) return false
-
- const now = new Date()
- const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null
-
- // 1. 입찰 마감일이 지났으면 무조건 가능
- if (submissionEndDate && now > submissionEndDate) return true
-
- // 2. 입찰 기간 내 조기개찰 조건 확인
- // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
- const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
- const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
-
- return isEarlyOpenPossible
- }, [selectedBiddingForAction])
-
- const handleOpenBidding = React.useCallback(async () => {
- if (!selectedBiddingForAction) return
-
- setIsOpeningBidding(true)
- try {
- const result = await openBiddingAction(selectedBiddingForAction.id)
- if (result.success) {
- toast.success("개찰이 완료되었습니다.")
- // 데이터 리프레시
- window.location.reload()
- } else {
- toast.error(result.message || "개찰에 실패했습니다.")
- }
- } catch (error) {
- toast.error("개찰 중 오류가 발생했습니다.")
- } finally {
- setIsOpeningBidding(false)
- }
- }, [selectedBiddingForAction])
-
- return (
- <>
- <DataTable
- table={table}
- compact={isCompact}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- enableCompactToggle={true}
- compactStorageKey="biddingsReceiveTableCompact"
- onCompactChange={handleCompactChange}
- >
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleOpenBidding}
- disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding}
- >
- {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 개찰
- </Button>
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 사양설명회 다이얼로그 */}
- {/* <SpecificationMeetingDialog
- open={specMeetingDialogOpen}
- onOpenChange={handleSpecMeetingDialogClose}
- bidding={selectedBidding}
- /> */}
-
- {/* PR 문서 다이얼로그 */}
- {/* <PrDocumentsDialog
- open={prDocumentsDialogOpen}
- onOpenChange={handlePrDocumentsDialogClose}
- bidding={selectedBidding}
- /> */}
-
- {/* 참여 협력사 다이얼로그 */}
- <BiddingParticipantsDialog
- open={participantsDialogOpen}
- onOpenChange={setParticipantsDialogOpen}
- biddingId={selectedBiddingId}
- participantType={selectedParticipantType}
- companies={participantCompanies}
- />
- </>
- )
-}
+"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" +import { Button } from "@/components/ui/button" +import { Loader2 } from "lucide-react" +import { toast } from "sonner" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getBiddingsReceiveColumns } from "./biddings-receive-columns" +import { getBiddingsForReceive } from "@/lib/bidding/service" +import { + biddingStatusLabels, + contractTypeLabels, +} from "@/db/schema" +// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs" +import { openBiddingAction } from "@/lib/bidding/actions" +import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog" +import { getAllBiddingCompanies } from "@/lib/bidding/detail/service" + +type BiddingReceiveItem = { + id: number + biddingNumber: string + originalBiddingNumber: string | null + title: string + status: string + contractType: string + prNumber: string | null + submissionStartDate: Date | null + submissionEndDate: Date | null + bidPicName: string | null + supplyPicName: string | null + createdBy: string | null + createdAt: Date | null + updatedAt: Date | null + + // 참여 현황 + participantExpected: number + participantParticipated: number + participantDeclined: number + participantPending: number + participantFinalSubmitted: number + + // 개찰 정보 + openedAt: Date | null + openedBy: string | null +} + +interface BiddingsReceiveTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getBiddingsForReceive>> + ] + > +} + +export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { + const [biddingsResult] = React.use(promises) + + // biddingsResult에서 data와 pageCount 추출 + const { data, pageCount } = biddingsResult + + const [isCompact, setIsCompact] = React.useState<boolean>(false) + // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false) + // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false) + const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null) + const [isOpeningBidding, setIsOpeningBidding] = React.useState(false) + + // 협력사 다이얼로그 관련 상태 + const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false) + const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null) + const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null) + const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([]) + const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false) + + const router = useRouter() + const { data: session } = useSession() + + // 협력사 클릭 핸들러 + const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => { + setSelectedBiddingId(biddingId) + setSelectedParticipantType(participantType) + setIsLoadingParticipants(true) + setParticipantsDialogOpen(true) + + try { + // 협력사 데이터 로드 (모든 초대된 협력사) + const companies = await getAllBiddingCompanies(biddingId) + + console.log('Loaded companies:', companies) + + // 필터링 없이 모든 데이터 그대로 표시 + // invitationStatus가 그대로 다이얼로그에 표시됨 + setParticipantCompanies(companies) + } catch (error) { + console.error('Failed to load participant companies:', error) + toast.error('협력사 목록을 불러오는데 실패했습니다.') + setParticipantCompanies([]) + } finally { + setIsLoadingParticipants(false) + } + }, []) + + const columns = React.useMemo( + () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }), + [setRowAction, handleParticipantClick] + ) + + // rowAction 변경 감지하여 해당 다이얼로그 열기 + React.useEffect(() => { + if (rowAction) { + setSelectedBidding(rowAction.row.original) + + switch (rowAction.type) { + case "view": + // 상세 페이지로 이동 + router.push(`/evcp/bid/${rowAction.row.original.id}`) + break + default: + break + } + } + }, [rowAction, router]) + + const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [ + { + id: "biddingNumber", + label: "입찰번호", + placeholder: "입찰번호를 입력하세요", + }, + { + id: "prNumber", + label: "P/R번호", + placeholder: "P/R번호를 입력하세요", + }, + { + id: "title", + label: "입찰명", + placeholder: "입찰명을 입력하세요", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<BiddingReceiveItem>[] = [ + { id: "title", label: "입찰명", type: "text" }, + { id: "biddingNumber", label: "입찰번호", type: "text" }, + { id: "bidPicName", label: "입찰담당자", type: "text" }, + { + id: "status", + label: "진행상태", + type: "multi-select", + options: Object.entries(biddingStatusLabels).map(([value, label]) => ({ + label, + value, + })), + }, + { + id: "contractType", + label: "계약구분", + type: "select", + options: Object.entries(contractTypeLabels).map(([value, label]) => ({ + label, + value, + })), + }, + { id: "createdAt", label: "등록일", type: "date" }, + { id: "submissionStartDate", label: "제출시작일", type: "date" }, + { id: "submissionEndDate", label: "제출마감일", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + enableRowSelection: true, + enableMultiRowSelection: false, // 단일 선택만 가능 + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + const handleCompactChange = React.useCallback((compact: boolean) => { + setIsCompact(compact) + }, []) + + + // 선택된 행 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null + + // 개찰 가능 여부 확인 + const canOpen = React.useMemo(() => { + if (!selectedBiddingForAction) return false + + const now = new Date() + const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null + + // 1. 입찰 마감일이 지났으면 무조건 가능 + if (submissionEndDate && now > submissionEndDate) return true + + // 2. 입찰 기간 내 조기개찰 조건 확인 + // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기) + const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined + const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected + + return isEarlyOpenPossible + }, [selectedBiddingForAction]) + + const handleOpenBidding = React.useCallback(async () => { + if (!selectedBiddingForAction) return + + setIsOpeningBidding(true) + try { + const result = await openBiddingAction(selectedBiddingForAction.id) + if (result.success) { + toast.success("개찰이 완료되었습니다.") + // 데이터 리프레시 + window.location.reload() + } else { + toast.error(result.message || "개찰에 실패했습니다.") + } + } catch (error) { + toast.error("개찰 중 오류가 발생했습니다.") + } finally { + setIsOpeningBidding(false) + } + }, [selectedBiddingForAction]) + + return ( + <> + <DataTable + table={table} + compact={isCompact} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + enableCompactToggle={true} + compactStorageKey="biddingsReceiveTableCompact" + onCompactChange={handleCompactChange} + > + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleOpenBidding} + disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding} + > + {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 개찰 + </Button> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 사양설명회 다이얼로그 */} + {/* <SpecificationMeetingDialog + open={specMeetingDialogOpen} + onOpenChange={handleSpecMeetingDialogClose} + bidding={selectedBidding} + /> */} + + {/* PR 문서 다이얼로그 */} + {/* <PrDocumentsDialog + open={prDocumentsDialogOpen} + onOpenChange={handlePrDocumentsDialogClose} + bidding={selectedBidding} + /> */} + + {/* 참여 협력사 다이얼로그 */} + <BiddingParticipantsDialog + open={participantsDialogOpen} + onOpenChange={setParticipantsDialogOpen} + biddingId={selectedBiddingId} + participantType={selectedParticipantType} + companies={participantCompanies} + /> + </> + ) +} diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts index f19fbe6d..06dcbea1 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 { @@ -168,12 +237,14 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) { .where(eq(biddings.originalBiddingNumber, baseNumber)) .orderBy(biddings.createdAt) - // 각 bidding에 대한 벤더의 견적 정보 조회 + // 각 bidding에 대한 벤더의 견적 정보 및 상세 아이템 조회 const historyPromises = relatedBiddings.map(async (bidding) => { + // 1. 견적 헤더 정보 조회 (ID 포함) const biddingCompanyData = await db .select({ + id: biddingCompanies.id, finalQuoteAmount: biddingCompanies.finalQuoteAmount, - responseSubmittedAt: biddingCompanies.responseSubmittedAt, + responseSubmittedAt: biddingCompanies.finalQuoteSubmittedAt, isFinalSubmission: biddingCompanies.isFinalSubmission }) .from(biddingCompanies) @@ -187,84 +258,72 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) { return null } - return { - biddingId: bidding.id, - biddingNumber: bidding.biddingNumber, - finalQuoteAmount: biddingCompanyData[0].finalQuoteAmount, - responseSubmittedAt: biddingCompanyData[0].responseSubmittedAt, - isFinalSubmission: biddingCompanyData[0].isFinalSubmission, - targetPrice: bidding.targetPrice, - currency: bidding.currency - } - }) - - const historyData = (await Promise.all(historyPromises)).filter(Boolean) - - // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등) - const sortedHistory = historyData.sort((a, b) => { - const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0 - const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0 - return aSuffix - bSuffix - }) - - // PR 항목 정보 조회 (현재 bidding 기준) - const prItems = await db - .select({ - id: prItemsForBidding.id, - itemNumber: prItemsForBidding.itemNumber, - itemInfo: prItemsForBidding.itemInfo, - quantity: prItemsForBidding.quantity, - quantityUnit: prItemsForBidding.quantityUnit, - requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate - }) - .from(prItemsForBidding) - .where(eq(prItemsForBidding.biddingId, biddingId)) - - // 각 히스토리 항목에 대한 PR 아이템 견적 조회 - const history = await Promise.all(sortedHistory.map(async (item, index) => { - // 각 bidding에 대한 PR 아이템 견적 조회 + // 2. 아이템별 견적 및 상세 정보 조회 (Join 사용) const prItemBids = await db .select({ - prItemId: companyPrItemBids.prItemId, + // 견적 정보 bidUnitPrice: companyPrItemBids.bidUnitPrice, bidAmount: companyPrItemBids.bidAmount, - proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, + // 아이템 상세 정보 + prItemId: prItemsForBidding.id, + itemNumber: prItemsForBidding.itemNumber, + itemInfo: prItemsForBidding.itemInfo, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate }) .from(companyPrItemBids) - .where(and( - eq(companyPrItemBids.biddingId, item!.biddingId), - eq(companyPrItemBids.companyId, vendorId) - )) + .innerJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id)) + .where(eq(companyPrItemBids.biddingCompanyId, biddingCompanyData[0].id)) - const targetPrice = item!.targetPrice ? parseFloat(item!.targetPrice.toString()) : null - const totalAmount = parseFloat(item!.finalQuoteAmount.toString()) + // 아이템 매핑 + const items = prItemBids.map(bid => ({ + itemCode: bid.itemNumber || `ITEM${bid.prItemId}`, + itemName: bid.itemInfo || '품목 정보 없음', + quantity: bid.quantity ? parseFloat(bid.quantity.toString()) : 0, + unit: bid.quantityUnit || 'EA', + unitPrice: bid.bidUnitPrice ? parseFloat(bid.bidUnitPrice.toString()) : 0, + totalPrice: bid.bidAmount ? parseFloat(bid.bidAmount.toString()) : 0, + deliveryDate: bid.proposedDeliveryDate + ? new Date(bid.proposedDeliveryDate) + : bid.requestedDeliveryDate + ? new Date(bid.requestedDeliveryDate) + : new Date() + })) + + const targetPrice = bidding.targetPrice ? parseFloat(bidding.targetPrice.toString()) : null + const totalAmount = parseFloat(biddingCompanyData[0].finalQuoteAmount.toString()) const vsTargetPrice = targetPrice && targetPrice > 0 ? ((totalAmount - targetPrice) / targetPrice) * 100 : 0 - const items = prItemBids.map(bid => { - const prItem = prItems.find(p => p.id === bid.prItemId) - return { - itemCode: prItem?.itemNumber || `ITEM${bid.prItemId}`, - itemName: prItem?.itemInfo || '품목 정보 없음', - quantity: prItem?.quantity || 0, - unit: prItem?.quantityUnit || 'EA', - unitPrice: parseFloat(bid.bidUnitPrice.toString()), - totalPrice: parseFloat(bid.bidAmount.toString()), - deliveryDate: bid.proposedDeliveryDate ? new Date(bid.proposedDeliveryDate) : prItem?.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : new Date() - } - }) - return { - id: item!.biddingId, - round: index + 1, // 1차, 2차, 3차... - submittedAt: new Date(item!.responseSubmittedAt), + biddingId: bidding.id, + biddingNumber: bidding.biddingNumber, + submittedAt: new Date(biddingCompanyData[0].responseSubmittedAt), totalAmount, - currency: item!.currency || 'KRW', + currency: bidding.currency || 'KRW', vsTargetPrice: parseFloat(vsTargetPrice.toFixed(2)), items } + }) + + const historyData = (await Promise.all(historyPromises)).filter(Boolean) + + // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등) + const sortedHistory = historyData.sort((a, b) => { + const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0 + const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0 + return aSuffix - bSuffix + }) + + // 회차 정보 추가 + const history = sortedHistory.map((item, index) => ({ + id: item!.biddingId, + round: index + 1, // 1차, 2차, 3차... + ...item! })) return { diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx index 8864e7db..5904bf65 100644 --- a/lib/bidding/selection/bidding-info-card.tsx +++ b/lib/bidding/selection/bidding-info-card.tsx @@ -5,6 +5,18 @@ import { Badge } from '@/components/ui/badge' // import { formatDate } from '@/lib/utils' import { biddingStatusLabels, contractTypeLabels } from '@/db/schema' +// 입찰유형 라벨 맵 추가 +const biddingTypeLabels: Record<string, string> = { + equipment: '기자재', + construction: '공사', + service: '용역', + lease: '임차', + transport: '운송', + waste: '폐기물', + sale: '매각', + other: '기타(직접입력)', +} + interface BiddingInfoCardProps { bidding: Bidding } @@ -56,7 +68,7 @@ export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) { 입찰유형 </label> <div className="text-sm font-medium"> - {bidding.isPublic ? '공개입찰' : '비공개입찰'} + {biddingTypeLabels[bidding.biddingType as keyof typeof biddingTypeLabels] || 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..aa2b34ec --- /dev/null +++ b/lib/bidding/selection/bidding-item-table.tsx @@ -0,0 +1,205 @@ +'use client' + +import * as React from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { getBiddingSelectionItemsAndPrices } from '@/lib/bidding/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(() => { + let isMounted = true + + const loadData = async () => { + try { + setLoading(true) + const { prItems, vendorPrices } = await getBiddingSelectionItemsAndPrices(biddingId) + + if (isMounted) { + console.log('prItems', prItems) + console.log('vendorPrices', vendorPrices) + setData({ prItems, vendorPrices }) + } + } catch (error) { + console.error('Failed to load bidding items:', error) + } finally { + if (isMounted) { + setLoading(false) + } + } + } + + loadData() + + return () => { + isMounted = false + } + }, [biddingId]) + + // Memoize calculations + const totals = React.useMemo(() => { + const { prItems } = data + return { + quantity: prItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0), + weight: prItems.reduce((sum, item) => sum + Number(item.totalWeight || 0), 0), + targetAmount: prItems.reduce((sum, item) => sum + Number(item.targetAmount || 0), 0) + } + }, [data.prItems]) + + const vendorTotals = React.useMemo(() => { + const { vendorPrices } = data + return vendorPrices.map(vendor => { + const total = vendor.itemPrices.reduce((sum: number, item: any) => sum + Number(item.amount || 0), 0) + return { + companyId: vendor.companyId, + totalAmount: total + } + }) + }, [data.vendorPrices]) + + 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 + + + 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(totals.quantity)}</td> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(totals.weight)}</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(totals.targetAmount)}</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 = totals.targetAmount > 0 ? (vTotal / totals.targetAmount) * 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-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx index 87c489e3..030fc05b 100644 --- a/lib/bidding/selection/biddings-selection-columns.tsx +++ b/lib/bidding/selection/biddings-selection-columns.tsx @@ -177,14 +177,13 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps): const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // 비교로직만 유지, 색상표기/마감뱃지 제거
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-xs">
<div>
- {formatKst(startObj)} ~ {formatKst(endObj)}
+ {formatValue(startObj)} ~ {formatValue(endObj)}
</div>
</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..ed20ad0c 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, @@ -39,8 +40,11 @@ import { import { revalidatePath } from 'next/cache' import { filterColumns } from '@/lib/filter-columns' import { GetBiddingsSchema, CreateBiddingSchema } from './validation' -import { saveFile } from '../file-stroage' - +import { saveFile, saveBuffer } from '../file-stroage' +import { decryptBufferWithServerAction } from '@/components/drm/drmUtils' +import { getVendorPricesForBidding } from './detail/service' +import { getPrItemsForBidding } from './pre-quote/service' +import { checkChemicalSubstance, checkMultipleChemicalSubstances, type ChemicalSubstanceResult } from '@/lib/soap/ecc/send/chemical-substance-check' // 사용자 이메일로 사용자 코드 조회 @@ -59,6 +63,27 @@ export async function getUserCodeByEmail(email: string): Promise<string | null> } } +// 사용자 ID로 상세 정보 조회 (이름, 코드 등) +export async function getUserDetails(userId: number) { + try { + const user = await db + .select({ + id: users.id, + name: users.name, + userCode: users.userCode, + employeeNumber: users.employeeNumber + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1) + + return user[0] || null + } catch (error) { + console.error('Failed to get user details:', error) + return null + } +} + // userId를 user.name으로 변환하는 유틸리티 함수 async function getUserNameById(userId: string): Promise<string> { try { @@ -419,9 +444,10 @@ export async function getBiddings(input: GetBiddingsSchema) { // 메타 정보 remarks: biddings.remarks, updatedAt: biddings.updatedAt, - updatedBy: biddings.updatedBy, + updatedBy: users.name, }) .from(biddings) + .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`) .where(finalWhere) .orderBy(...orderByColumns) .limit(input.perPage) @@ -846,7 +872,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { .insert(biddings) .values({ biddingNumber, - originalBiddingNumber: null, // 원입찰번호는 초기 생성이므로 아직 없음 + originalBiddingNumber: biddingNumber.split('-')[0], revision: input.revision || 0, // 프로젝트 정보 (PR 아이템에서 설정됨) @@ -872,7 +898,6 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { biddingRegistrationDate: new Date(), submissionStartDate: parseDate(input.submissionStartDate), submissionEndDate: parseDate(input.submissionEndDate), - evaluationDate: parseDate(input.evaluationDate), hasSpecificationMeeting: input.hasSpecificationMeeting || false, hasPrDocument: input.hasPrDocument || false, @@ -911,6 +936,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { await tx.insert(biddingNoticeTemplate).values({ biddingId, title: input.title + ' 입찰공고', + type: input.noticeType || 'standard', content: input.content || standardContent, isTemplate: false, }) @@ -1721,7 +1747,6 @@ export async function updateBiddingBasicInfo( contractEndDate?: string submissionStartDate?: string submissionEndDate?: string - evaluationDate?: string hasSpecificationMeeting?: boolean hasPrDocument?: boolean currency?: string @@ -1779,9 +1804,23 @@ export async function updateBiddingBasicInfo( // 정의된 필드들만 업데이트 if (updates.title !== undefined) updateData.title = updates.title if (updates.description !== undefined) updateData.description = updates.description - if (updates.content !== undefined) updateData.content = updates.content + // content는 bidding 테이블에 컬럼이 없음, notice content는 별도로 저장해야 함 + // if (updates.content !== undefined) updateData.content = updates.content if (updates.noticeType !== undefined) updateData.noticeType = updates.noticeType if (updates.contractType !== undefined) updateData.contractType = updates.contractType + + // 입찰공고 내용 저장 + if (updates.content !== undefined) { + try { + await saveBiddingNotice(biddingId, { + title: (updates.title || '') + ' 입찰공고', // 제목이 없으면 기존 제목을 가져오거나 해야하는데, 여기서는 업데이트된 제목 사용 + content: updates.content + }) + } catch (e) { + console.error('Failed to save bidding notice content:', e) + // 공고 저장 실패는 전체 업데이트 실패로 처리하지 않음 (로그만 남김) + } + } if (updates.biddingType !== undefined) updateData.biddingType = updates.biddingType if (updates.biddingTypeCustom !== undefined) updateData.biddingTypeCustom = updates.biddingTypeCustom if (updates.awardCount !== undefined) updateData.awardCount = updates.awardCount @@ -1793,7 +1832,6 @@ export async function updateBiddingBasicInfo( if (updates.contractEndDate !== undefined) updateData.contractEndDate = parseDate(updates.contractEndDate) if (updates.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(updates.submissionStartDate) if (updates.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(updates.submissionEndDate) - if (updates.evaluationDate !== undefined) updateData.evaluationDate = parseDate(updates.evaluationDate) if (updates.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = updates.hasSpecificationMeeting if (updates.hasPrDocument !== undefined) updateData.hasPrDocument = updates.hasPrDocument if (updates.currency !== undefined) updateData.currency = updates.currency @@ -1877,12 +1915,14 @@ export async function updateBiddingBasicInfo( } } -// 입찰 일정 업데이트 +// 입찰 일정 업데이트 (오프셋 기반) export async function updateBiddingSchedule( biddingId: number, schedule: { - submissionStartDate?: string - submissionEndDate?: string + submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일) + submissionStartTime?: string // 시작 시간 (HH:MM) + submissionDurationDays?: number // 기간 (시작일 + n일) + submissionEndTime?: string // 마감 시간 (HH:MM) remarks?: string isUrgent?: boolean hasSpecificationMeeting?: boolean @@ -1913,14 +1953,28 @@ export async function updateBiddingSchedule( return new Date(`${dateStr}:00+09:00`) } + // 시간 문자열(HH:MM)을 임시 timestamp로 변환 (1970-01-01 HH:MM:00 UTC) + // 결재 완료 시 실제 날짜로 계산됨 + const timeToTimestamp = (timeStr?: string): Date | null => { + if (!timeStr) return null + const [hours, minutes] = timeStr.split(':').map(Number) + const date = new Date(0) // 1970-01-01 00:00:00 UTC + date.setUTCHours(hours, minutes, 0, 0) + return date + } + return await db.transaction(async (tx) => { const updateData: any = { updatedAt: new Date(), updatedBy: userName, } - if (schedule.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(schedule.submissionStartDate) || null - if (schedule.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(schedule.submissionEndDate) || null + // 오프셋 기반 필드 저장 + if (schedule.submissionStartOffset !== undefined) updateData.submissionStartOffset = schedule.submissionStartOffset + if (schedule.submissionDurationDays !== undefined) updateData.submissionDurationDays = schedule.submissionDurationDays + // 시간은 timestamp 필드에 임시 저장 (1970-01-01 HH:MM:00) + if (schedule.submissionStartTime !== undefined) updateData.submissionStartDate = timeToTimestamp(schedule.submissionStartTime) + if (schedule.submissionEndTime !== undefined) updateData.submissionEndDate = timeToTimestamp(schedule.submissionEndTime) if (schedule.remarks !== undefined) updateData.remarks = schedule.remarks if (schedule.isUrgent !== undefined) updateData.isUrgent = schedule.isUrgent if (schedule.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = schedule.hasSpecificationMeeting @@ -2196,7 +2250,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 +2268,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 +2565,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: { @@ -2758,10 +2925,13 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u // 2. 입찰번호 생성 (타입에 따라 다르게 처리) let newBiddingNumber: string + let originalBiddingNumber: string if (type === 'rebidding') { // 재입찰: 완전히 새로운 입찰번호 생성 newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx) + // 재입찰시에도 원입찰번호는 새로 생성된 입찰번호로 셋팅 + originalBiddingNumber = newBiddingNumber.split('-')[0] } else { // 차수증가: 기존 입찰번호에서 차수 증가 const currentBiddingNumber = existingBidding.biddingNumber @@ -2771,16 +2941,18 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u let currentRound = match ? parseInt(match[1]) : 1 if (currentRound >= 3) { - // -03 이상이면 새로운 번호 생성 + // -03 이상이면 재입찰이며, 새로운 번호 생성 newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx) + // 새로 생성한 입찰번호를 원입찰번호로 셋팅 + originalBiddingNumber = newBiddingNumber.split('-')[0] } else { // -02까지는 차수만 증가 const baseNumber = currentBiddingNumber.split('-')[0] newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}` + // 차수증가의 경우에도 원입찰번호는 새로 생성한 입찰번호로 셋팅 + originalBiddingNumber = newBiddingNumber.split('-')[0] } } - //원입찰번호는 -0n 제외하고 저장 - const originalBiddingNumber = existingBidding.biddingNumber.split('-')[0] // 3. 새로운 입찰 생성 (기존 정보 복제) const [newBidding] = await tx @@ -2793,13 +2965,15 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u // 기본 정보 복제 projectName: existingBidding.projectName, + projectCode: existingBidding.projectCode, // 프로젝트 코드 복제 itemName: existingBidding.itemName, title: existingBidding.title, description: existingBidding.description, // 계약 정보 복제 contractType: existingBidding.contractType, - biddingType: existingBidding.biddingType, + noticeType: existingBidding.noticeType, // 공고타입 복제 + biddingType: existingBidding.biddingType, // 구매유형 복제 awardCount: existingBidding.awardCount, contractStartDate: existingBidding.contractStartDate, contractEndDate: existingBidding.contractEndDate, @@ -2809,7 +2983,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u biddingRegistrationDate: new Date(), submissionStartDate: null, submissionEndDate: null, - evaluationDate: null, // 사양설명회 hasSpecificationMeeting: existingBidding.hasSpecificationMeeting, @@ -2819,6 +2992,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u budget: existingBidding.budget, targetPrice: existingBidding.targetPrice, targetPriceCalculationCriteria: existingBidding.targetPriceCalculationCriteria, + actualPrice: existingBidding.actualPrice, finalBidPrice: null, // 최종입찰가는 초기화 // PR 정보 복제 @@ -2832,6 +3006,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u // 구매조직 purchasingOrganization: existingBidding.purchasingOrganization, + plant: existingBidding.plant, // 담당자 정보 복제 bidPicId: existingBidding.bidPicId, @@ -3074,8 +3249,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u .from(biddingDocuments) .where(and( eq(biddingDocuments.biddingId, biddingId), - // PR 아이템에 연결된 첨부파일은 제외 (SHI용과 협력업체용만 복제) - isNull(biddingDocuments.prItemId), // SHI용(evaluation_doc) 또는 협력업체용(company_proposal) 문서만 복제 or( eq(biddingDocuments.documentType, 'evaluation_doc'), @@ -3086,32 +3259,34 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u if (existingDocuments.length > 0) { for (const doc of existingDocuments) { try { - // 기존 파일을 Buffer로 읽어서 File 객체 생성 - const { readFileSync, existsSync } = await import('fs') + // 기존 파일 경로 확인 및 Buffer로 읽기 + const { readFile, access, constants } = await import('fs/promises') const { join } = await import('path') + // 파일 경로 정규화 const oldFilePath = doc.filePath.startsWith('/uploads/') ? join(process.cwd(), 'public', doc.filePath) + : doc.filePath.startsWith('/') + ? join(process.cwd(), 'public', doc.filePath) : doc.filePath - if (!existsSync(oldFilePath)) { - console.warn(`원본 파일이 존재하지 않음: ${oldFilePath}`) + // 파일 존재 여부 확인 + try { + await access(oldFilePath, constants.R_OK) + } catch { + console.warn(`원본 파일이 존재하지 않거나 읽을 수 없음: ${oldFilePath}`) continue } - // 파일 내용을 읽어서 Buffer 생성 - const fileBuffer = readFileSync(oldFilePath) - - // Buffer를 File 객체로 변환 (브라우저 File API 시뮬레이션) - const file = new File([fileBuffer], doc.fileName, { - type: doc.mimeType || 'application/octet-stream' - }) + // 파일 내용을 Buffer로 읽기 + const fileBuffer = await readFile(oldFilePath) - // saveFile을 사용하여 새 파일 저장 - const saveResult = await saveFile({ - file, + // saveBuffer를 사용하여 새 파일 저장 (File 객체 변환 없이 직접 저장) + const saveResult = await saveBuffer({ + buffer: fileBuffer, + fileName: doc.fileName, directory: `biddings/${newBidding.id}/attachments/${doc.documentType === 'evaluation_doc' ? 'shi' : 'vendor'}`, - originalName: `copied_${Date.now()}_${doc.fileName}`, + originalName: doc.originalFileName || doc.fileName, userId: userName }) @@ -3145,9 +3320,10 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u } } - revalidatePath('/bidding') - revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신 - revalidatePath(`/bidding/${newBidding.id}`) + revalidatePath('/bid-receive') + revalidatePath('/evcp/bid-receive') + revalidatePath('/evcp/bid') + revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신 return { success: true, @@ -3436,9 +3612,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'), )! ) @@ -3704,7 +3881,7 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { // 유찰 정보 (업데이트 일시를 유찰일로 사용) disposalDate: biddings.updatedAt, // 유찰일 disposalUpdatedAt: biddings.updatedAt, // 폐찰수정일 - disposalUpdatedBy: biddings.updatedBy, // 폐찰수정자 + disposalUpdatedBy: users.name, // 폐찰수정자 // 폐찰 정보 closureReason: biddings.description, // 폐찰사유 @@ -3719,9 +3896,10 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { createdBy: biddings.createdBy, createdAt: biddings.createdAt, updatedAt: biddings.updatedAt, - updatedBy: biddings.updatedBy, + updatedBy: users.name, }) .from(biddings) + .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`) .leftJoin(biddingDocuments, and( eq(biddingDocuments.biddingId, biddings.id), eq(biddingDocuments.documentType, 'evaluation_doc'), // 폐찰 문서 @@ -3791,4 +3969,378 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { console.error("Error in getBiddingsForFailure:", err) return { data: [], pageCount: 0, total: 0 } } -}
\ No newline at end of file +} + + +export async function getBiddingSelectionItemsAndPrices(biddingId: number) { + try { + const [prItems, vendorPrices] = await Promise.all([ + getPrItemsForBidding(biddingId), + getVendorPricesForBidding(biddingId) + ]) + + return { + prItems, + vendorPrices + } + } catch (error) { + console.error('Failed to get bidding selection items and prices:', error) + throw error + } +} + +// ======================================== +// 화학물질 조회 및 저장 관련 함수들 +// ======================================== + +/** + * 입찰 참여업체의 화학물질 정보를 조회하고 DB에 저장 + */ +// export async function checkAndSaveChemicalSubstanceForBiddingCompany(biddingCompanyId: number) { +// try { +// // 입찰 참여업체 정보 조회 (벤더 정보 포함) +// const biddingCompanyInfo = await db +// .select({ +// id: biddingCompanies.id, +// biddingId: biddingCompanies.biddingId, +// companyId: biddingCompanies.companyId, +// hasChemicalSubstance: biddingCompanies.hasChemicalSubstance, +// vendors: { +// vendorCode: vendors.vendorCode +// } +// }) +// .from(biddingCompanies) +// .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) +// .where(eq(biddingCompanies.id, biddingCompanyId)) +// .limit(1) + +// if (!biddingCompanyInfo[0]) { +// throw new Error(`입찰 참여업체를 찾을 수 없습니다: ${biddingCompanyId}`) +// } + +// const companyInfo = biddingCompanyInfo[0] + +// // 이미 화학물질 검사가 완료된 경우 스킵 +// if (companyInfo.hasChemicalSubstance !== null && companyInfo.hasChemicalSubstance !== undefined) { +// console.log(`이미 화학물질 검사가 완료된 입찰 참여업체: ${biddingCompanyId}`) +// return { +// success: true, +// message: '이미 화학물질 검사가 완료되었습니다.', +// hasChemicalSubstance: companyInfo.hasChemicalSubstance +// } +// } + +// // 벤더 코드가 없는 경우 스킵 +// if (!companyInfo.vendors?.vendorCode) { +// console.log(`벤더 코드가 없는 입찰 참여업체: ${biddingCompanyId}`) +// return { +// success: false, +// message: '벤더 코드가 없습니다.' +// } +// } + +// // 입찰의 PR 아이템들 조회 (자재번호 있는 것만) +// const prItems = await db +// .select({ +// id: prItemsForBidding.id, +// materialNumber: prItemsForBidding.materialNumber +// }) +// .from(prItemsForBidding) +// .where(and( +// eq(prItemsForBidding.biddingId, companyInfo.biddingId), +// isNotNull(prItemsForBidding.materialNumber), +// sql`${prItemsForBidding.materialNumber} != ''` +// )) + +// if (prItems.length === 0) { +// console.log(`자재번호가 있는 PR 아이템이 없는 입찰: ${companyInfo.biddingId}`) +// return { +// success: false, +// message: '조회할 자재가 없습니다.' +// } +// } + +// // 각 자재에 대해 화학물질 조회 +// let hasAnyChemicalSubstance = false +// const results: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = [] + +// for (const prItem of prItems) { +// try { +// const checkResult = await checkChemicalSubstance({ +// bukrs: 'H100', // 회사코드는 H100 고정 +// werks: 'PM11', // WERKS는 PM11 고정 +// lifnr: companyInfo.vendors.vendorCode, +// matnr: prItem.materialNumber! +// }) + +// if (checkResult.success) { +// const itemHasChemical = checkResult.hasChemicalSubstance || false +// hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical + +// results.push({ +// materialNumber: prItem.materialNumber!, +// hasChemicalSubstance: itemHasChemical, +// message: checkResult.message || '조회 성공' +// }) +// } else { +// results.push({ +// materialNumber: prItem.materialNumber!, +// hasChemicalSubstance: false, +// message: checkResult.message || '조회 실패' +// }) +// } + +// // API 호출 간 지연 +// await new Promise(resolve => setTimeout(resolve, 500)) + +// } catch (error) { +// results.push({ +// materialNumber: prItem.materialNumber!, +// hasChemicalSubstance: false, +// message: error instanceof Error ? error.message : 'Unknown error' +// }) +// } +// } + +// // 하나라도 Y(Y=true)이면 true, 모두 N(false)이면 false +// const finalHasChemicalSubstance = hasAnyChemicalSubstance + +// // DB에 결과 저장 +// await db +// .update(biddingCompanies) +// .set({ +// hasChemicalSubstance: finalHasChemicalSubstance, +// updatedAt: new Date() +// }) +// .where(eq(biddingCompanies.id, biddingCompanyId)) + +// console.log(`화학물질 정보 저장 완료: 입찰 참여업체 ${biddingCompanyId}, 화학물질 ${finalHasChemicalSubstance ? '있음' : '없음'} (${results.filter(r => r.hasChemicalSubstance).length}/${results.length})`) + +// return { +// success: true, +// message: `화학물질 조회 및 저장이 완료되었습니다. (${results.filter(r => r.hasChemicalSubstance).length}/${results.length}개 자재에 화학물질 있음)`, +// hasChemicalSubstance: finalHasChemicalSubstance, +// results +// } + +// } catch (error) { +// console.error(`화학물질 조회 실패 (입찰 참여업체 ${biddingCompanyId}):`, error) +// return { +// success: false, +// message: error instanceof Error ? error.message : 'Unknown error', +// hasChemicalSubstance: null, +// results: [] +// } +// } +// } + +/** + * 입찰의 모든 참여업체에 대한 화학물질 정보를 일괄 조회하고 저장 + */ +export async function checkAndSaveChemicalSubstancesForBidding(biddingId: number) { + try { + const [biddingInfo] = await db + .select({ + id: biddings.id, + ANFNR: biddings.ANFNR, + plant: biddings.plant, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!biddingInfo) { + return { + success: false, + message: '입찰 정보를 찾을 수 없습니다.', + results: [] + } + } + + if (!biddingInfo.ANFNR) { + return { + success: true, + message: 'SAP PR 연동 입찰이 아니므로 화학물질 검사를 건너뜁니다.', + results: [] + } + } + + const biddingWerks = biddingInfo.plant?.trim() + if (!biddingWerks) { + return { + success: false, + message: '입찰의 플랜트(WERKS) 정보가 없어 화학물질 검사를 진행할 수 없습니다.', + results: [] + } + } + + // 입찰의 모든 참여업체 조회 (벤더 코드 있는 것만) + const biddingCompaniesList = await db + .select({ + id: biddingCompanies.id, + companyId: biddingCompanies.companyId, + hasChemicalSubstance: biddingCompanies.hasChemicalSubstance, + vendors: { + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName + } + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + isNotNull(vendors.vendorCode), + sql`${vendors.vendorCode} != ''` + )) + + if (biddingCompaniesList.length === 0) { + return { + success: true, + message: '벤더 코드가 있는 참여업체가 없습니다.', + results: [] + } + } + + // 입찰의 PR 아이템들 조회 (자재번호 있는 것만) + const prItems = await db + .select({ + materialNumber: prItemsForBidding.materialNumber + }) + .from(prItemsForBidding) + .where(and( + eq(prItemsForBidding.biddingId, biddingId), + isNotNull(prItemsForBidding.materialNumber), + sql`${prItemsForBidding.materialNumber} != ''` + )) + + if (prItems.length === 0) { + return { + success: false, + message: '조회할 자재가 없습니다.', + results: [] + } + } + + const materialNumbers = prItems.map(item => item.materialNumber!).filter(Boolean) + + // 각 참여업체에 대해 화학물질 조회 + const results: Array<{ + biddingCompanyId: number; + vendorCode: string; + vendorName: string; + success: boolean; + hasChemicalSubstance?: boolean; + message: string; + materialResults?: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }>; + }> = [] + + for (const biddingCompany of biddingCompaniesList) { + try { + // 이미 검사가 완료된 경우 스킵 + if (biddingCompany.hasChemicalSubstance !== null && biddingCompany.hasChemicalSubstance !== undefined) { + results.push({ + biddingCompanyId: biddingCompany.id, + vendorCode: biddingCompany.vendors!.vendorCode!, + vendorName: biddingCompany.vendors!.vendorName || '', + success: true, + hasChemicalSubstance: biddingCompany.hasChemicalSubstance, + message: '이미 검사가 완료되었습니다.' + }) + continue + } + + // 각 자재에 대해 화학물질 조회 + let hasAnyChemicalSubstance = false + const materialResults: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = [] + + for (const materialNumber of materialNumbers) { + try { + const checkResult = await checkChemicalSubstance({ + bukrs: 'H100', // 회사코드는 H100 고정 + werks: biddingWerks, + lifnr: biddingCompany.vendors!.vendorCode!, + matnr: materialNumber + }) + + if (checkResult.success) { + const itemHasChemical = checkResult.hasChemicalSubstance || false + hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical + + materialResults.push({ + materialNumber, + hasChemicalSubstance: itemHasChemical, + message: checkResult.message || '조회 성공' + }) + } else { + materialResults.push({ + materialNumber, + hasChemicalSubstance: false, + message: checkResult.message || '조회 실패' + }) + } + + // API 호출 간 지연 + await new Promise(resolve => setTimeout(resolve, 500)) + + } catch (error) { + materialResults.push({ + materialNumber, + hasChemicalSubstance: false, + message: error instanceof Error ? error.message : 'Unknown error' + }) + } + } + + // 하나라도 Y이면 true, 모두 N이면 false + const finalHasChemicalSubstance = hasAnyChemicalSubstance + + // DB에 결과 저장 + await db + .update(biddingCompanies) + .set({ + hasChemicalSubstance: finalHasChemicalSubstance, + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompany.id)) + + results.push({ + biddingCompanyId: biddingCompany.id, + vendorCode: biddingCompany.vendors!.vendorCode!, + vendorName: biddingCompany.vendors!.vendorName || '', + success: true, + hasChemicalSubstance: finalHasChemicalSubstance, + message: `조회 완료 (${materialResults.filter(r => r.hasChemicalSubstance).length}/${materialResults.length}개 자재에 화학물질 있음)`, + materialResults + }) + + } catch (error) { + results.push({ + biddingCompanyId: biddingCompany.id, + vendorCode: biddingCompany.vendors!.vendorCode!, + vendorName: biddingCompany.vendors!.vendorName || '', + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }) + } + } + + const successCount = results.filter(r => r.success).length + const totalCount = results.length + + console.log(`입찰 ${biddingId} 화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`) + + return { + success: true, + message: `화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`, + results + } + + } catch (error) { + console.error(`입찰 화학물질 일괄 조회 실패 (${biddingId}):`, error) + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error', + results: [] + } + } +} diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts index 73c2fe21..3254ae7e 100644 --- a/lib/bidding/validation.ts +++ b/lib/bidding/validation.ts @@ -99,7 +99,6 @@ export const createBiddingSchema = z.object({ submissionEndDate: z.string().optional(), - evaluationDate: z.string().optional(), // 회의 및 문서 hasSpecificationMeeting: z.boolean().default(false), @@ -220,7 +219,6 @@ export const createBiddingSchema = z.object({ submissionStartDate: z.string().optional(), submissionEndDate: z.string().optional(), - evaluationDate: z.string().optional(), hasSpecificationMeeting: z.boolean().optional(), hasPrDocument: z.boolean().optional(), diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx index 7dd8384e..6910e360 100644 --- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx +++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx @@ -4,7 +4,17 @@ import * as React from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' - +import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { Table, @@ -16,10 +26,12 @@ import { } from '@/components/ui/table' import { Package, - Download, - Calculator + Calculator, + CalendarIcon } from 'lucide-react' +import { format } from 'date-fns' +import { cn } from '@/lib/utils' import { formatDate } from '@/lib/utils' import { downloadFile, formatFileSize, getFileInfo } from '@/lib/file-download' import { getSpecDocumentsForPrItem } from '../../pre-quote/service' @@ -186,6 +198,8 @@ export function PrItemsPricingTable({ }: PrItemsPricingTableProps) { const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([]) const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({}) + const [showBulkDateDialog, setShowBulkDateDialog] = React.useState(false) + const [bulkDeliveryDate, setBulkDeliveryDate] = React.useState<Date | undefined>(undefined) // 초기 견적 데이터 설정 및 SPEC 문서 로드 React.useEffect(() => { @@ -279,6 +293,21 @@ export function PrItemsPricingTable({ onTotalAmountChange(totalAmount) } + // 일괄 납기일 적용 + const applyBulkDeliveryDate = () => { + if (bulkDeliveryDate && quotations.length > 0) { + const formattedDate = format(bulkDeliveryDate, 'yyyy-MM-dd') + const updatedQuotations = quotations.map(q => ({ + ...q, + proposedDeliveryDate: formattedDate + })) + + setQuotations(updatedQuotations) + onQuotationsChange(updatedQuotations) + setShowBulkDateDialog(false) + setBulkDeliveryDate(undefined) + } + } // 통화 포맷팅 const formatCurrency = (amount: number) => { @@ -292,12 +321,26 @@ export function PrItemsPricingTable({ const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0) return ( + <> <Card> <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Package className="w-5 h-5" /> - 품목별 입찰 작성 - </CardTitle> + <div className="flex items-center justify-between"> + <CardTitle className="flex items-center gap-2"> + <Package className="w-5 h-5" /> + 품목별 입찰 작성 + </CardTitle> + {!readOnly && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={() => setShowBulkDateDialog(true)} + > + <CalendarIcon className="h-4 w-4 mr-1" /> + 전체 납품예정일 설정 + </Button> + )} + </div> </CardHeader> <CardContent> <div className="space-y-4"> @@ -382,18 +425,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', @@ -471,5 +510,73 @@ export function PrItemsPricingTable({ </div> </CardContent> </Card> + + {/* 일괄 납품예정일 설정 다이얼로그 */} + <Dialog open={showBulkDateDialog} onOpenChange={setShowBulkDateDialog}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>전체 납품예정일 설정</DialogTitle> + <DialogDescription> + 모든 PR 아이템에 동일한 납품예정일을 적용합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="space-y-2"> + <Label>납품예정일 선택</Label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + className={cn( + "w-full justify-start text-left font-normal", + !bulkDeliveryDate && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {bulkDeliveryDate ? format(bulkDeliveryDate, "yyyy-MM-dd") : "날짜 선택"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={bulkDeliveryDate} + onSelect={setBulkDeliveryDate} + initialFocus + /> + </PopoverContent> + </Popover> + </div> + + <div className="bg-muted/50 rounded-lg p-3"> + <p className="text-sm text-muted-foreground"> + 선택된 날짜가 <strong>{prItems.length}개</strong>의 모든 PR 아이템에 적용됩니다. + 기존에 설정된 납품예정일은 모두 교체됩니다. + </p> + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + setShowBulkDateDialog(false) + setBulkDeliveryDate(undefined) + }} + > + 취소 + </Button> + <Button + type="button" + onClick={applyBulkDeliveryDate} + disabled={!bulkDeliveryDate} + > + 전체 적용 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> ) } 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..e1d985fe --- /dev/null +++ b/lib/bidding/vendor/export-partners-biddings-to-excel.ts @@ -0,0 +1,275 @@ +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) + + // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') + + value = `${formatValue(startObj)} ~ ${formatValue(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-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx index d0ef97f1..8d6cb82d 100644 --- a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx +++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx @@ -37,7 +37,6 @@ interface PartnersSpecificationMeetingDialogProps { title: string preQuoteDate: string | null biddingRegistrationDate: string | null - evaluationDate: string | null hasSpecificationMeeting?: boolean // 사양설명회 여부 추가 } | null biddingCompanyId: number diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index bf76de62..bf33cef5 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -75,7 +75,6 @@ interface BiddingDetail { biddingRegistrationDate: Date | string | null submissionStartDate: Date | string | null submissionEndDate: Date | string | null - evaluationDate: Date | string | null currency: string budget: number | null targetPrice: number | null @@ -869,7 +868,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const timeLeft = deadline.getTime() - now.getTime() const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) - const kstDeadline = new Date(deadline.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + // 입력값 기반: 저장된 UTC 값을 그대로 표시 + const displayDeadline = deadline.toISOString().slice(0, 16).replace('T', ' ') return ( <div className={`p-3 rounded-lg border-2 ${ @@ -884,7 +884,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <Calendar className="w-5 h-5" /> <span className="font-medium">제출 마감일:</span> <span className="text-lg font-semibold"> - {kstDeadline} + {displayDeadline} </span> </div> {isExpired ? ( @@ -921,17 +921,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <span className="font-medium">입찰서 제출기간:</span> {(() => { const start = new Date(biddingDetail.submissionStartDate!) const end = new Date(biddingDetail.submissionEndDate!) - const kstStart = new Date(start.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') - const kstEnd = new Date(end.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') - return `${kstStart} ~ ${kstEnd}` + const displayStart = start.toISOString().slice(0, 16).replace('T', ' ') + const displayEnd = end.toISOString().slice(0, 16).replace('T', ' ') + return `${displayStart} ~ ${displayEnd}` })()} </div> )} - {biddingDetail.evaluationDate && ( - <div> - <span className="font-medium">평가일:</span> {format(new Date(biddingDetail.evaluationDate), "yyyy-MM-dd HH:mm")} - </div> - )} + </div> </div> </CardContent> diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index a122e87b..09c3caad 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" /> @@ -352,45 +352,45 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL const startObj = new Date(startDate) const endObj = new Date(endDate) - // UI 표시용 KST 변환 - const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') return ( <div className="text-sm"> - <div>{formatKst(startObj)}</div> + <div>{formatValue(startObj)}</div> <div className="text-muted-foreground">~</div> - <div>{formatKst(endObj)}</div> + <div>{formatValue(endObj)}</div> </div> ) }, }), // 사전견적 마감일 - 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-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx index 0f68ed68..f1cb0bdc 100644 --- a/lib/bidding/vendor/partners-bidding-list.tsx +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -181,7 +181,6 @@ export function PartnersBiddingList({ promises }: PartnersBiddingListProps) { title: rowAction.row.original.title, preQuoteDate: null, biddingRegistrationDate: rowAction.row.original.submissionStartDate?.toISOString() || null, - evaluationDate: null, hasSpecificationMeeting: rowAction.row.original.hasSpecificationMeeting || false, } : null} biddingCompanyId={rowAction?.row.original?.biddingCompanyId || 0} 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/dolce/actions.ts b/lib/dolce/actions.ts index 501c6cb0..fe246956 100644 --- a/lib/dolce/actions.ts +++ b/lib/dolce/actions.ts @@ -20,6 +20,7 @@ export interface DwgReceiptItem { CreateUserENM: string | null; CreateUserId: string | null; CreateUserNo: string; + DetailDwgCNT: number; Discipline: string; DrawingKind: string; DrawingMoveGbn: string; @@ -44,6 +45,7 @@ export interface GttDwgReceiptItem { CreateUserENM: string; CreateUserId: string; CreateUserNo: string; + DetailDwgCNT: number; DGbn: string | null; DegreeGbn: string | null; DeptGbn: string | null; @@ -946,7 +948,7 @@ export async function prepareB4DetailDrawingsV2(params: { DrawingRevNo: revNo, Category: category, Receiver: null, - Manager: drawingInfo.Manager || "970043", + Manager: drawingInfo.ManagerNo, RegisterDesc: "", UploadId: uploadId, RegCompanyCode: vendorCode, @@ -1188,7 +1190,7 @@ export async function bulkUploadB4FilesV2( DrawingRevNo: revNo, Category: category, Receiver: null, - Manager: drawingInfo.Manager || "970043", + Manager: drawingInfo.ManagerNo, RegisterDesc: registerDesc, UploadId: uploadId, RegCompanyCode: vendorCode, diff --git a/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx b/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx index d4318b90..2d2532d7 100644 --- a/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx +++ b/lib/dolce/dialogs/add-and-modify-detail-drawing-dialog.tsx @@ -272,7 +272,7 @@ export function AddAndModifyDetailDrawingDialog({ DrawingRevNo: drawingUsage === "CMT" ? null : revision, Category: "TS", // To SHI (벤더가 SHI에게 제출) Receiver: null, - Manager: "", + Manager: drawing.ManagerNo, RegisterDesc: comment, UploadId: uploadId, RegCompanyCode: vendorCode, diff --git a/lib/dolce/table/drawing-list-columns.tsx b/lib/dolce/table/drawing-list-columns.tsx index 30b71d8d..6fe1b3e2 100644 --- a/lib/dolce/table/drawing-list-columns.tsx +++ b/lib/dolce/table/drawing-list-columns.tsx @@ -28,12 +28,21 @@ export function drawingListColumns(lng: string, t: any): ColumnDef<DwgReceiptIte minSize: 120, }, { + accessorKey: "DetailDwgCNT", + header: t("drawingList.columns.detailDwgCnt"), + minSize: 100, + cell: ({ row }) => { + const count = row.getValue("DetailDwgCNT") as number; + return <div className="text-center">{count || 0}</div>; + }, + }, + { accessorKey: "Manager", header: t("drawingList.columns.manager"), minSize: 200, cell: ({ row }) => { const managerENM = row.original.ManagerENM; - const manager = row.getValue("Manager"); + const manager = row.getValue("Manager") as string; return <div>{managerENM || manager}</div>; }, }, diff --git a/lib/dolce/table/gtt-drawing-list-columns.tsx b/lib/dolce/table/gtt-drawing-list-columns.tsx index 94d4d7d1..c76fcba0 100644 --- a/lib/dolce/table/gtt-drawing-list-columns.tsx +++ b/lib/dolce/table/gtt-drawing-list-columns.tsx @@ -41,12 +41,21 @@ export function createGttDrawingListColumns({ minSize: 80, }, { + accessorKey: "DetailDwgCNT", + header: t("drawingList.columns.detailDwgCnt"), + minSize: 100, + cell: ({ row }) => { + const count = row.getValue("DetailDwgCNT") as number; + return <div className="text-center">{count || 0}</div>; + }, + }, + { accessorKey: "Manager", header: t("drawingList.columns.manager"), minSize: 200, cell: ({ row }) => { const managerENM = row.original.ManagerENM; - const manager = row.getValue("Manager"); + const manager = row.getValue("Manager") as string; return <div>{managerENM || manager}</div>; }, }, @@ -56,7 +65,7 @@ export function createGttDrawingListColumns({ minSize: 120, cell: ({ row }) => { - const drawingMoveGbn = row.getValue("DrawingMoveGbn"); + const drawingMoveGbn = row.getValue("DrawingMoveGbn") as string; let type = ""; if (drawingMoveGbn == "도면입수") { diff --git a/lib/forms-plant/services.ts b/lib/forms-plant/services.ts index 3f50bd47..64d353de 100644 --- a/lib/forms-plant/services.ts +++ b/lib/forms-plant/services.ts @@ -21,7 +21,7 @@ import { VendorDataReportTempsPlant, } from "@/db/schema/vendorData"; import { eq, and, desc, sql, DrizzleError, inArray, or, type SQL, type InferSelectModel } from "drizzle-orm"; -import { unstable_cache } from "next/cache"; +import { unstable_cache ,unstable_noStore } from "next/cache"; import { revalidateTag } from "next/cache"; import { getErrorMessage } from "../handle-error"; import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns"; @@ -234,9 +234,10 @@ export async function getEditableFieldsByTag( * 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱. */ export async function getFormData(formCode: string, projectCode: string, packageCode:string) { + unstable_noStore(); try { - console.log(formCode,projectCode, packageCode) + // console.log(formCode,projectCode, packageCode) const project = await db.query.projects.findFirst({ where: eq(projects.code, projectCode), @@ -329,83 +330,84 @@ export async function getFormData(formCode: string, projectCode: string, package console.error(`[getFormData] Cache operation failed:`, cacheError); // Fallback logic (기존과 동일하게 editableFieldsMap 추가) - try { - console.log(`[getFormData] Fallback DB query for (${formCode}, ${packageCode})`); - - const project = await db.query.projects.findFirst({ - where: eq(projects.code, projectCode), - columns: { - id: true - } - }); - - const projectId = project.id; - - const metaRows = await db - .select() - .from(formMetas) - .where( - and( - eq(formMetas.formCode, formCode), - eq(formMetas.projectId, projectId) - ) - ) - .orderBy(desc(formMetas.updatedAt)) - .limit(1); - - const meta = metaRows[0] ?? null; - if (!meta) { - console.warn(`[getFormData] Fallback: No form meta found for formCode: ${formCode} and projectId: ${projectId}`); - return { columns: null, data: [], editableFieldsMap: new Map() }; - } - - const entryRows = await db - .select() - .from(formEntriesPlant) - .where( - and( - eq(formEntriesPlant.formCode, formCode), - eq(formEntriesPlant.projectCode, projectCode), - eq(formEntriesPlant.packageCode, packageCode) - ) - ) - .orderBy(desc(formEntriesPlant.updatedAt)) - .limit(1); - - const entry = entryRows[0] ?? null; - - let columns = meta.columns as DataTableColumnJSON[]; - const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; - columns = columns.filter(col => !excludeKeys.includes(col.key)); - - columns.forEach((col) => { - if (!col.displayLabel) { - if (col.uom) { - col.displayLabel = `${col.label} (${col.uom})`; - } else { - col.displayLabel = col.label; - } - } - }); - - let data: Array<Record<string, any>> = []; - if (entry) { - if (Array.isArray(entry.data)) { - data = entry.data; - } else { - console.warn("formEntries data was not an array. Using empty array (fallback)."); - } - } - - // Fallback에서도 편집 가능 필드 정보 계산 - const editableFieldsMap = await getEditableFieldsByTag(projectCode, packageCode, projectId); - - return { columns, data, projectId, editableFieldsMap }; - } catch (dbError) { - console.error(`[getFormData] Fallback DB query failed:`, dbError); - return { columns: null, data: [], editableFieldsMap: new Map() }; - } - } + // try { + // console.log(`[getFormData] Fallback DB query for (${formCode}, ${packageCode})`); + + // const project = await db.query.projects.findFirst({ + // where: eq(projects.code, projectCode), + // columns: { + // id: true + // } + // }); + + // const projectId = project.id; + + // const metaRows = await db + // .select() + // .from(formMetas) + // .where( + // and( + // eq(formMetas.formCode, formCode), + // eq(formMetas.projectId, projectId) + // ) + // ) + // .orderBy(desc(formMetas.updatedAt)) + // .limit(1); + + // const meta = metaRows[0] ?? null; + // if (!meta) { + // console.warn(`[getFormData] Fallback: No form meta found for formCode: ${formCode} and projectId: ${projectId}`); + // return { columns: null, data: [], editableFieldsMap: new Map() }; + // } + + // const entryRows = await db + // .select() + // .from(formEntriesPlant) + // .where( + // and( + // eq(formEntriesPlant.formCode, formCode), + // eq(formEntriesPlant.projectCode, projectCode), + // eq(formEntriesPlant.packageCode, packageCode) + // ) + // ) + // .orderBy(desc(formEntriesPlant.updatedAt)) + // .limit(1); + + // const entry = entryRows[0] ?? null; + + // let columns = meta.columns as DataTableColumnJSON[]; + // const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; + // columns = columns.filter(col => !excludeKeys.includes(col.key)); + + // columns.forEach((col) => { + // if (!col.displayLabel) { + // if (col.uom) { + // col.displayLabel = `${col.label} (${col.uom})`; + // } else { + // col.displayLabel = col.label; + // } + // } + // }); + + // let data: Array<Record<string, any>> = []; + // if (entry) { + // if (Array.isArray(entry.data)) { + // data = entry.data; + // } else { + // console.warn("formEntries data was not an array. Using empty array (fallback)."); + // } + // } + + // // Fallback에서도 편집 가능 필드 정보 계산 + // const editableFieldsMap = await getEditableFieldsByTag(projectCode, packageCode, projectId); + + // return { columns, data, projectId, editableFieldsMap }; + // } catch (dbError) { + // console.error(`[getFormData] Fallback DB query failed:`, dbError); + // return { columns: null, data: [], editableFieldsMap: new Map() }; + // } + // } +} } /** * contractId와 formCode(itemCode)를 사용하여 contractItemId를 찾는 서버 액션 @@ -1052,6 +1054,7 @@ type GetReportFileList = ( }>; export const getFormId: GetReportFileList = async (projectCode, packageCode, formCode, mode) => { + unstable_noStore(); const result: { formId: number } = { formId: 0, }; diff --git a/lib/general-contracts/approval-actions.ts b/lib/general-contracts/approval-actions.ts new file mode 100644 index 00000000..e75d6cd6 --- /dev/null +++ b/lib/general-contracts/approval-actions.ts @@ -0,0 +1,136 @@ +/** + * 일반계약 관련 결재 서버 액션 + * + * 사용자가 UI에서 호출하는 함수들 + * ApprovalSubmissionSaga를 사용하여 결재 프로세스를 시작 + */ + +'use server'; + +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import { mapContractToApprovalTemplateVariables } from './approval-template-variables'; +import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; +import db from '@/db/db'; +import { eq } from 'drizzle-orm'; +import { generalContracts } from '@/db/schema/generalContract'; +import { users } from '@/db/schema'; + +interface ContractSummary { + basicInfo: Record<string, unknown>; + items: Record<string, unknown>[]; + subcontractChecklist: Record<string, unknown> | null; + storageInfo?: Record<string, unknown>[]; + pdfPath?: string; + basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>; +} + +/** + * 결재를 거쳐 일반계약 승인 요청을 처리하는 서버 액션 + * + * 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestContractApprovalWithApproval({ + * contractId: 123, + * contractSummary: summaryData, + * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, + * approvers: ['EP002', 'EP003'], + * title: '계약 체결 진행 품의 요청서' + * }); + * + * if (result.status === 'pending_approval') { + * console.log('결재 ID:', result.approvalId); + * } + * ``` + */ +export async function requestContractApprovalWithApproval(data: { + contractId: number; + contractSummary: ContractSummary; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; // Knox EP ID 배열 (결재선) + title?: string; // 결재 제목 (선택사항, 미지정 시 자동 생성) +}) { + debugLog('[ContractApproval] 일반계약 승인 요청 결재 서버 액션 시작', { + contractId: data.contractId, + contractNumber: data.contractSummary.basicInfo?.contractNumber, + contractName: data.contractSummary.basicInfo?.name, + userId: data.currentUser.id, + hasEpId: !!data.currentUser.epId, + }); + + // 입력 검증 + if (!data.currentUser.epId) { + debugError('[ContractApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다'); + } + + if (!data.contractId) { + debugError('[ContractApproval] 계약 ID 없음'); + throw new Error('계약 ID가 필요합니다'); + } + + // 1. 유저의 nonsapUserId 조회 (Cronjob 환경을 위해) + debugLog('[ContractApproval] nonsapUserId 조회'); + const userResult = await db.query.users.findFirst({ + where: eq(users.id, data.currentUser.id), + columns: { nonsapUserId: true } + }); + const nonsapUserId = userResult?.nonsapUserId || null; + debugLog('[ContractApproval] nonsapUserId 조회 완료', { nonsapUserId }); + + // 2. 템플릿 변수 매핑 + debugLog('[ContractApproval] 템플릿 변수 매핑 시작'); + const variables = await mapContractToApprovalTemplateVariables(data.contractSummary); + debugLog('[ContractApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + // 3. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[ContractApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'general_contract_approval', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 + { + contractId: data.contractId, + contractSummary: data.contractSummary, + currentUser: { + id: data.currentUser.id, + email: data.currentUser.email, + nonsapUserId: nonsapUserId, + }, + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title: data.title || `계약 체결 진행 품의 요청서 - ${data.contractSummary.basicInfo?.contractNumber || data.contractId}`, + description: `${data.contractSummary.basicInfo?.name || '일반계약'} 계약 체결 진행 품의 요청`, + templateName: '일반계약 결재', // 한국어 템플릿명 + variables, // 치환할 변수들 + approvers: data.approvers, + currentUser: data.currentUser, + } + ); + + debugLog('[ContractApproval] Saga 실행 시작'); + const result = await saga.execute(); + + // 4. 결재 상신 성공 시 상태를 'approval_in_progress'로 변경 + if (result.status === 'pending_approval') { + debugLog('[ContractApproval] 상태를 approval_in_progress로 변경'); + await db.update(generalContracts) + .set({ + status: 'approval_in_progress', + lastUpdatedAt: new Date() + }) + .where(eq(generalContracts.id, data.contractId)); + } + + debugSuccess('[ContractApproval] 결재 워크플로우 완료', { + approvalId: result.approvalId, + status: result.status, + }); + + return result; +} + diff --git a/lib/general-contracts/approval-template-variables.ts b/lib/general-contracts/approval-template-variables.ts new file mode 100644 index 00000000..710e6101 --- /dev/null +++ b/lib/general-contracts/approval-template-variables.ts @@ -0,0 +1,345 @@ +/** + * 일반계약 결재 템플릿 변수 매핑 함수 + * + * 제공된 HTML 템플릿의 변수명에 맞춰 매핑 + */ + +'use server'; + +import { format } from 'date-fns'; + +interface ContractSummary { + basicInfo: Record<string, unknown>; + items: Record<string, unknown>[]; + subcontractChecklist: Record<string, unknown> | null; + storageInfo?: Record<string, unknown>[]; +} + +/** + * 일반계약 데이터를 결재 템플릿 변수로 매핑 + * + * @param contractSummary - 계약 요약 정보 + * @returns 템플릿 변수 객체 (Record<string, string>) + */ +export async function mapContractToApprovalTemplateVariables( + contractSummary: ContractSummary +): Promise<Record<string, string>> { + const { basicInfo, items, subcontractChecklist } = contractSummary; + + // 날짜 포맷팅 헬퍼 + const formatDate = (date: any) => { + if (!date) return ''; + try { + const d = new Date(date); + if (isNaN(d.getTime())) return String(date); + return format(d, 'yyyy-MM-dd'); + } catch { + return String(date || ''); + } + }; + + // 금액 포맷팅 헬퍼 + const formatCurrency = (amount: any) => { + if (amount === undefined || amount === null || amount === '') return ''; + const num = Number(amount); + if (isNaN(num)) return String(amount); + return num.toLocaleString('ko-KR'); + }; + + // 계약기간 포맷팅 + const contractPeriod = basicInfo.startDate && basicInfo.endDate + ? `${formatDate(basicInfo.startDate)} ~ ${formatDate(basicInfo.endDate)}` + : ''; + + // 계약체결방식 + const contractExecutionMethod = basicInfo.executionMethod || ''; + + // 계약종류 + const contractType = basicInfo.type || ''; + + // 업체선정방식 + const vendorSelectionMethod = basicInfo.contractSourceType || ''; + + // 매입 부가가치세 + const taxType = basicInfo.taxType || ''; + + // SHI 지급조건 + const paymentTerm = basicInfo.paymentTerm || ''; + + // SHI 인도조건 + const deliveryTerm = basicInfo.deliveryTerm || ''; + const deliveryType = basicInfo.deliveryType || ''; + + // 사외업체 야드 투입 여부 + const externalYardEntry = basicInfo.externalYardEntry === 'Y' ? '예' : '아니오'; + + // 직종 + const workType = basicInfo.workType || ''; + + // 재하도 협력사 + const subcontractVendor = basicInfo.subcontractVendorName || ''; + + // 계약 내용 + const contractContent = basicInfo.notes || basicInfo.name || ''; + + // 계약성립조건 + let establishmentConditionsText = ''; + if (basicInfo.contractEstablishmentConditions) { + try { + const cond = typeof basicInfo.contractEstablishmentConditions === 'string' + ? JSON.parse(basicInfo.contractEstablishmentConditions) + : basicInfo.contractEstablishmentConditions; + + const active: string[] = []; + if (cond.regularVendorRegistration) active.push('정규업체 등록(실사 포함) 시'); + if (cond.projectAward) active.push('프로젝트 수주 시'); + if (cond.ownerApproval) active.push('선주 승인 시'); + if (cond.other) active.push('기타'); + establishmentConditionsText = active.join(', '); + } catch (e) { + console.warn('계약성립조건 파싱 실패:', e); + } + } + + // 계약해지조건 + let terminationConditionsText = ''; + if (basicInfo.contractTerminationConditions) { + try { + const cond = typeof basicInfo.contractTerminationConditions === 'string' + ? JSON.parse(basicInfo.contractTerminationConditions) + : basicInfo.contractTerminationConditions; + + const active: string[] = []; + if (cond.standardTermination) active.push('표준 계약해지조건'); + if (cond.projectNotAwarded) active.push('프로젝트 미수주 시'); + if (cond.other) active.push('기타'); + terminationConditionsText = active.join(', '); + } catch (e) { + console.warn('계약해지조건 파싱 실패:', e); + } + } + + // 협력사 정보 + const vendorCode = basicInfo.vendorCode || ''; + const vendorName = basicInfo.vendorName || ''; + const vendorContactPerson = basicInfo.vendorContactPerson || ''; + const vendorPhone = basicInfo.vendorPhone || ''; + const vendorEmail = basicInfo.vendorEmail || ''; + const vendorNote = ''; + + // 자재 정보 (최대 100건) + const materialItems = items.slice(0, 100); + const materialCount = items.length; + + // 보증 정보 + const guarantees: Array<{ + type: string; + order: number; + bondNumber: string; + rate: string; + amount: string; + period: string; + startDate: string; + endDate: string; + issuer: string; + }> = []; + + // // 계약보증 (첫 번째 항목만 사용) + // if (basicInfo.contractBond) { + // const bond = typeof basicInfo.contractBond === 'string' + // ? JSON.parse(basicInfo.contractBond) + // : basicInfo.contractBond; + + // if (bond && Array.isArray(bond) && bond.length > 0) { + // const b = bond[0]; + // guarantees.push({ + // type: '계약보증', + // order: 1, + // bondNumber: b.bondNumber || '', + // rate: b.rate ? `${b.rate}%` : '', + // amount: formatCurrency(b.amount), + // period: b.period || '', + // startDate: formatDate(b.startDate), + // endDate: formatDate(b.endDate), + // issuer: b.issuer || '', + // }); + // } + // } + + // // 지급보증 (첫 번째 항목만 사용) + // if (basicInfo.paymentBond) { + // const bond = typeof basicInfo.paymentBond === 'string' + // ? JSON.parse(basicInfo.paymentBond) + // : basicInfo.paymentBond; + + // if (bond && Array.isArray(bond) && bond.length > 0) { + // const b = bond[0]; + // guarantees.push({ + // type: '지급보증', + // order: 1, + // bondNumber: b.bondNumber || '', + // rate: b.rate ? `${b.rate}%` : '', + // amount: formatCurrency(b.amount), + // period: b.period || '', + // startDate: formatDate(b.startDate), + // endDate: formatDate(b.endDate), + // issuer: b.issuer || '', + // }); + // } + // } + + // // 하자보증 (첫 번째 항목만 사용) + // if (basicInfo.defectBond) { + // const bond = typeof basicInfo.defectBond === 'string' + // ? JSON.parse(basicInfo.defectBond) + // : basicInfo.defectBond; + + // if (bond && Array.isArray(bond) && bond.length > 0) { + // const b = bond[0]; + // guarantees.push({ + // type: '하자보증', + // order: 1, + // bondNumber: b.bondNumber || '', + // rate: b.rate ? `${b.rate}%` : '', + // amount: formatCurrency(b.amount), + // period: b.period || '', + // startDate: formatDate(b.startDate), + // endDate: formatDate(b.endDate), + // issuer: b.issuer || '', + // }); + // } + // } + + // // 보증 전체 비고 + // const guaranteeNote = basicInfo.guaranteeNote || ''; + + + // 총 계약 금액 계산 + const totalContractAmount = items.reduce((sum, item) => { + const amount = Number(item.contractAmount || item.totalLineAmount || 0); + return sum + (isNaN(amount) ? 0 : amount); + }, 0); + + // 변수 매핑 + const variables: Record<string, string> = { + // 계약 기본 정보 + '계약번호': String(basicInfo.contractNumber || ''), + '계약명': String(basicInfo.name || basicInfo.contractName || ''), + '계약체결방식': String(contractExecutionMethod), + '계약종류': String(contractType), + '구매담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''), + '업체선정방식': String(vendorSelectionMethod), + '입찰번호': String(basicInfo.linkedBidNumber || ''), + '입찰명': String(basicInfo.linkedBidName || ''), + '계약기간': contractPeriod, + '계약일자': formatDate(basicInfo.registeredAt || basicInfo.createdAt), + '매입_부가가치세': String(taxType), + '계약_담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''), + '계약부서': String(basicInfo.departmentName || ''), + '계약금액': formatCurrency(basicInfo.contractAmount), + 'SHI_지급조건': String(paymentTerm), + 'SHI_인도조건': String(deliveryTerm), + 'SHI_인도조건_옵션': String(deliveryType), + '선적지': String(basicInfo.shippingLocation || ''), + '하역지': String(basicInfo.dischargeLocation || ''), + '사외업체_야드_투입여부': externalYardEntry, + '프로젝트': String(basicInfo.projectName || basicInfo.projectCode || ''), + '직종': String(workType), + '재하도_협력사': String(subcontractVendor), + '계약내용': String(contractContent), + '계약성립조건': establishmentConditionsText, + '계약해지조건': terminationConditionsText, + + // 협력사 정보 + '협력사코드': String(vendorCode), + '협력사명': String(vendorName), + '협력사_담당자': String(vendorContactPerson), + '전화번호': String(vendorPhone), + '이메일': String(vendorEmail), + '비고': String(vendorNote), + + // 자재 정보 + '대상_자재_수': String(materialCount), + }; + + // 자재 정보 변수 (최대 100건) + materialItems.forEach((item, index) => { + const idx = index + 1; + variables[`플랜트_${idx}`] = String(item.plant || ''); + variables[`프로젝트_${idx}`] = String(item.projectName || item.projectCode || ''); + variables[`자재그룹_${idx}`] = String(item.itemGroup || item.itemCode || ''); + variables[`자재그룹명_${idx}`] = String(item.itemGroupName || ''); + variables[`자재번호_${idx}`] = String(item.itemCode || ''); + variables[`자재상세_${idx}`] = String(item.itemInfo || item.description || ''); + variables[`연간단가여부_${idx}`] = String(item.isAnnualPrice ? '예' : '아니오'); + variables[`수량_${idx}`] = formatCurrency(item.quantity); + variables[`구매단위_${idx}`] = String(item.quantityUnit || ''); + variables[`계약단가_${idx}`] = formatCurrency(item.contractUnitPrice || item.unitPrice); + variables[`수량단위_${idx}`] = String(item.quantityUnit || ''); + variables[`총중량_${idx}`] = formatCurrency(item.totalWeight); + variables[`중량단위_${idx}`] = String(item.weightUnit || ''); + variables[`계약금액_${idx}`] = formatCurrency(item.contractAmount || item.totalLineAmount); + }); + + // 총 계약 금액 + variables['총_계약금액'] = formatCurrency(totalContractAmount); + + // // 보증 정보 변수 (첫 번째 항목만 사용) + // const contractGuarantee = guarantees.find(g => g.type === '계약보증'); + // if (contractGuarantee) { + // variables['계약보증_차수_1'] = String(contractGuarantee.order); + // variables['계약보증_증권번호_1'] = String(contractGuarantee.bondNumber || ''); + // variables['계약보증_보증금율_1'] = String(contractGuarantee.rate || ''); + // variables['계약보증_보증금액_1'] = String(contractGuarantee.amount || ''); + // variables['계약보증_보증기간_1'] = String(contractGuarantee.period || ''); + // variables['계약보증_시작일_1'] = String(contractGuarantee.startDate || ''); + // variables['계약보증_종료일_1'] = String(contractGuarantee.endDate || ''); + // variables['계약보증_발행기관_1'] = String(contractGuarantee.issuer || ''); + // variables['계약보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 계약보증 비고로 사용 + // } + + // const paymentGuarantee = guarantees.find(g => g.type === '지급보증'); + // if (paymentGuarantee) { + // variables['지급보증_차수_1'] = String(paymentGuarantee.order); + // variables['지급보증_증권번호_1'] = String(paymentGuarantee.bondNumber || ''); + // variables['지급보증_보증금율_1'] = String(paymentGuarantee.rate || ''); + // variables['지급보증_보증금액_1'] = String(paymentGuarantee.amount || ''); + // variables['지급보증_보증기간_1'] = String(paymentGuarantee.period || ''); + // variables['지급보증_시작일_1'] = String(paymentGuarantee.startDate || ''); + // variables['지급보증_종료일_1'] = String(paymentGuarantee.endDate || ''); + // variables['지급보증_발행기관_1'] = String(paymentGuarantee.issuer || ''); + // variables['지급보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 지급보증 비고로 사용 + // } + + // const defectGuarantee = guarantees.find(g => g.type === '하자보증'); + // if (defectGuarantee) { + // variables['하자보증_차수_1'] = String(defectGuarantee.order); + // variables['하자보증_증권번호_1'] = String(defectGuarantee.bondNumber || ''); + // variables['하자보증_보증금율_1'] = String(defectGuarantee.rate || ''); + // variables['하자보증_보증금액_1'] = String(defectGuarantee.amount || ''); + // variables['하자보증_보증기간_1'] = String(defectGuarantee.period || ''); + // variables['하자보증_시작일_1'] = String(defectGuarantee.startDate || ''); + // variables['하자보증_종료일_1'] = String(defectGuarantee.endDate || ''); + // variables['하자보증_발행기관_1'] = String(defectGuarantee.issuer || ''); + // variables['하자보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 하자보증 비고로 사용 + // } + + // 하도급 체크리스트 변수 (새로운 템플릿 구조에 맞춤) + if (subcontractChecklist) { + variables['작업전_서면발급_체크'] = String(subcontractChecklist.workDocumentIssuedCheck || subcontractChecklist.workDocumentIssued || ''); + variables['기재사항_1'] = String(subcontractChecklist.legalItem1 || subcontractChecklist.sixLegalItems1 || ''); + variables['기재사항_2'] = String(subcontractChecklist.legalItem2 || subcontractChecklist.sixLegalItems2 || ''); + variables['기재사항_3'] = String(subcontractChecklist.legalItem3 || subcontractChecklist.sixLegalItems3 || ''); + variables['기재사항_4'] = String(subcontractChecklist.legalItem4 || subcontractChecklist.sixLegalItems4 || ''); + variables['기재사항_5'] = String(subcontractChecklist.legalItem5 || subcontractChecklist.sixLegalItems5 || ''); + variables['기재사항_6'] = String(subcontractChecklist.legalItem6 || subcontractChecklist.sixLegalItems6 || ''); + variables['부당대금_결정'] = String(subcontractChecklist.unfairPriceDecision || subcontractChecklist.unfairSubcontractPrice || ''); + variables['점검결과'] = String(subcontractChecklist.inspectionResult || subcontractChecklist.overallResult || ''); + variables['귀책부서'] = String(subcontractChecklist.responsibleDepartment || subcontractChecklist.overallDepartment || ''); + variables['원인'] = String(subcontractChecklist.cause || subcontractChecklist.overallCause || ''); + variables['대책'] = String(subcontractChecklist.countermeasure || subcontractChecklist.overallMeasure || ''); + } + + return variables; +} + diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx index 46251c71..db0901cb 100644 --- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx +++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx @@ -1,1068 +1,1200 @@ -'use client'
-
-import React, { useState, useEffect } from 'react'
-import { useSession } from 'next-auth/react'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Badge } from '@/components/ui/badge'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Label } from '@/components/ui/label'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { Input } from '@/components/ui/input'
-import { toast } from 'sonner'
-import {
- FileText,
- Upload,
- Eye,
- Send,
- CheckCircle,
- Download,
- AlertCircle
-} from 'lucide-react'
-import { ContractDocuments } from './general-contract-documents'
-import { getActiveContractTemplates } from '@/lib/bidding/service'
-import { type BasicContractTemplate } from '@/db/schema'
-import {
- getBasicInfo,
- getContractItems,
- getSubcontractChecklist,
- uploadContractApprovalFile,
- sendContractApprovalRequest,
- getContractById,
- getContractTemplateByContractType,
- getStorageInfo
-} from '../service'
-import { mapContractDataToTemplateVariables } from '../utils'
-
-interface ContractApprovalRequestDialogProps {
- contract: Record<string, unknown>
- open: boolean
- onOpenChange: (open: boolean) => void
-}
-
-interface ContractSummary {
- basicInfo: Record<string, unknown>
- items: Record<string, unknown>[]
- subcontractChecklist: Record<string, unknown> | null
- storageInfo?: Record<string, unknown>[]
-}
-
-export function ContractApprovalRequestDialog({
- contract,
- open,
- onOpenChange
-}: ContractApprovalRequestDialogProps) {
- const { data: session } = useSession()
- const [currentStep, setCurrentStep] = useState(1)
- const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null)
- const [uploadedFile, setUploadedFile] = useState<File | null>(null)
- const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null)
- const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null)
- const [isLoading, setIsLoading] = useState(false)
- const [pdfViewerInstance, setPdfViewerInstance] = useState<any>(null)
- const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
-
- // 기본계약 관련 상태
- const [selectedBasicContracts, setSelectedBasicContracts] = useState<Array<{
- type: string;
- templateName: string;
- checked: boolean;
- }>>([])
- const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false)
-
- const contractId = contract.id as number
- const userId = session?.user?.id || ''
-
-
- // 기본계약 생성 함수 (최종 전송 시점에 호출)
- const generateBasicContractPdf = async (
- vendorId: number,
- contractType: string,
- templateName: string
- ): Promise<{ buffer: number[], fileName: string }> => {
- try {
- // 1. 템플릿 데이터 준비 (서버 액션 호출)
- const prepareResponse = await fetch("/api/contracts/prepare-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- templateName,
- vendorId,
- }),
- });
-
- if (!prepareResponse.ok) {
- const errorText = await prepareResponse.text();
- throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`);
- }
-
- const { template, templateData } = await prepareResponse.json();
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: template.filePath }),
- });
-
- const templateBlob = await templateResponse.blob();
- const templateFile = new window.File([templateBlob], "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- });
-
- // 3. PDFTron WebViewer로 PDF 변환
- const { default: WebViewer } = await import("@pdftron/webviewer");
-
- const tempDiv = document.createElement('div');
- tempDiv.style.display = 'none';
- document.body.appendChild(tempDiv);
-
- try {
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- enableOfficeEditing: true,
- },
- tempDiv
- );
-
- const { Core } = instance;
- const { createDocument } = Core;
-
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- });
-
- // 변수 치환 적용
- await templateDoc.applyTemplateValues(templateData);
- await new Promise(resolve => setTimeout(resolve, 3000));
-
- const fileData = await templateDoc.getFileData();
- const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
-
- const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`;
-
- instance.UI.dispose();
- return {
- buffer: Array.from(pdfBuffer),
- fileName
- };
-
- } finally {
- if (tempDiv.parentNode) {
- document.body.removeChild(tempDiv);
- }
- }
-
- } catch (error) {
- console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error);
- throw error;
- }
- };
-
- // 기본계약 생성 및 선택 초기화
- const initializeBasicContracts = React.useCallback(async () => {
- if (!contractSummary?.basicInfo) return;
-
- setIsLoadingBasicContracts(true);
- try {
- // 기본적으로 사용할 수 있는 계약서 타입들
- const availableContracts: Array<{
- type: string;
- templateName: string;
- checked: boolean;
- }> = [
- { type: "NDA", templateName: "비밀", checked: false },
- { type: "General_GTC", templateName: "General GTC", checked: false },
- { type: "기술자료", templateName: "기술", checked: false }
- ];
-
- // 프로젝트 코드가 있으면 Project GTC도 추가
- if (contractSummary.basicInfo.projectCode) {
- availableContracts.push({
- type: "Project_GTC",
- templateName: contractSummary.basicInfo.projectCode as string,
- checked: false
- });
- }
-
- setSelectedBasicContracts(availableContracts);
- } catch (error) {
- console.error('기본계약 초기화 실패:', error);
- toast.error('기본계약 초기화에 실패했습니다.');
- } finally {
- setIsLoadingBasicContracts(false);
- }
- }, [contractSummary]);
-
- // 기본계약 선택 토글
- const toggleBasicContract = (type: string) => {
- setSelectedBasicContracts(prev =>
- prev.map(contract =>
- contract.type === type
- ? { ...contract, checked: !contract.checked }
- : contract
- )
- );
- };
-
-
- // 1단계: 계약 현황 수집
- const collectContractSummary = React.useCallback(async () => {
- setIsLoading(true)
- try {
- // 각 컴포넌트에서 활성화된 데이터만 수집
- const summary: ContractSummary = {
- basicInfo: {},
- items: [],
- subcontractChecklist: null
- }
-
- // Basic Info 확인 (항상 활성화)
- try {
- const basicInfoData = await getBasicInfo(contractId)
- if (basicInfoData && basicInfoData.success) {
- summary.basicInfo = basicInfoData.data || {}
- }
- // externalYardEntry 정보도 추가로 가져오기
- const contractData = await getContractById(contractId)
- if (contractData) {
- summary.basicInfo = {
- ...summary.basicInfo,
- externalYardEntry: contractData.externalYardEntry || 'N'
- }
- }
- } catch {
- console.log('Basic Info 데이터 없음')
- }
-
- // 품목 정보 확인
- try {
- const itemsData = await getContractItems(contractId)
- if (itemsData && itemsData.length > 0) {
- summary.items = itemsData
- }
- } catch {
- console.log('품목 정보 데이터 없음')
- }
-
- try {
- // Subcontract Checklist 확인
- const subcontractData = await getSubcontractChecklist(contractId)
- if (subcontractData && subcontractData.success && subcontractData.enabled) {
- summary.subcontractChecklist = subcontractData.data
- }
- } catch {
- console.log('Subcontract Checklist 데이터 없음')
- }
-
- // 임치(물품보관) 계약 정보 확인 (SG)
- try {
- if (summary.basicInfo?.contractType === 'SG') {
- const storageData = await getStorageInfo(contractId)
- if (storageData && storageData.length > 0) {
- summary.storageInfo = storageData
- }
- }
- } catch {
- console.log('임치계약 정보 없음')
- }
-
- console.log('contractSummary 구조:', summary)
- console.log('basicInfo 내용:', summary.basicInfo)
- setContractSummary(summary)
- } catch (error) {
- console.error('Error collecting contract summary:', error)
- toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }, [contractId])
-
- // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
- const generatePdf = async () => {
- if (!contractSummary) {
- toast.error('계약 정보가 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // 1. 계약 유형에 맞는 템플릿 조회
- const contractType = contractSummary.basicInfo.contractType as string
- const templateResult = await getContractTemplateByContractType(contractType)
-
- if (!templateResult.success || !templateResult.template) {
- throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
- }
-
- const template = templateResult.template
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: template.filePath }),
- })
-
- if (!templateResponse.ok) {
- throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
- }
-
- const templateBlob = await templateResponse.blob()
- const templateFile = new File([templateBlob], template.fileName || "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- })
-
- // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
- const tempDiv = document.createElement('div')
- tempDiv.style.display = 'none'
- document.body.appendChild(tempDiv)
-
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- tempDiv
- )
-
- try {
- const { Core } = instance
- const { createDocument } = Core
-
- // 템플릿 문서 생성 및 변수 치환
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- })
-
- // 템플릿 변수 매핑
- const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
-
- console.log("🔄 변수 치환 시작:", mappedTemplateData)
- await templateDoc.applyTemplateValues(mappedTemplateData as any)
- console.log("✅ 변수 치환 완료")
-
- // PDF 변환
- const fileData = await templateDoc.getFileData()
- const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
-
- console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
-
- // PDF 버퍼를 Blob URL로 변환하여 미리보기
- const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- setGeneratedPdfUrl(pdfUrl)
-
- // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
- setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
-
- toast.success('PDF가 생성되었습니다.')
-
- } finally {
- // 임시 WebViewer 정리
- instance.UI.dispose()
- document.body.removeChild(tempDiv)
- }
-
- } catch (error: any) {
- console.error('❌ PDF 생성 실패:', error)
- const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
- toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 미리보기 기능
- const openPdfPreview = async () => {
- if (!generatedPdfBuffer) {
- toast.error('생성된 PDF가 없습니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 기존 인스턴스가 있다면 정리
- if (pdfViewerInstance) {
- console.log("🔄 기존 WebViewer 인스턴스 정리")
- try {
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('기존 WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 미리보기용 컨테이너 확인
- let previewDiv = document.getElementById('pdf-preview-container')
- if (!previewDiv) {
- console.log("🔄 컨테이너 생성")
- previewDiv = document.createElement('div')
- previewDiv.id = 'pdf-preview-container'
- previewDiv.className = 'w-full h-full'
- previewDiv.style.width = '100%'
- previewDiv.style.height = '100%'
-
- // 실제 컨테이너에 추가
- const actualContainer = document.querySelector('[data-pdf-container]')
- if (actualContainer) {
- actualContainer.appendChild(previewDiv)
- }
- }
-
- console.log("🔄 WebViewer 인스턴스 생성 시작")
-
- // WebViewer 인스턴스 생성 (문서 없이)
- const instance = await Promise.race([
- WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- previewDiv
- ),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
- )
- ])
-
- console.log("🔄 WebViewer 인스턴스 생성 완료")
- setPdfViewerInstance(instance)
-
- // PDF 버퍼를 Blob으로 변환
- const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- console.log("🔄 PDF Blob URL 생성:", pdfUrl)
-
- // 문서 로드
- console.log("🔄 문서 로드 시작")
- const { documentViewer } = (instance as any).Core
-
- // 문서 로드 이벤트 대기
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- reject(new Error('문서 로드 타임아웃'))
- }, 20000)
-
- const onDocumentLoaded = () => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.log("🔄 문서 로드 완료")
- resolve(true)
- }
-
- const onDocumentError = (error: any) => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.error('문서 로드 오류:', error)
- reject(error)
- }
-
- documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.addEventListener('documentError', onDocumentError)
-
- // 문서 로드 시작
- documentViewer.loadDocument(pdfUrl)
- })
-
- setIsPdfPreviewVisible(true)
- toast.success('PDF 미리보기가 준비되었습니다.')
-
- } catch (error) {
- console.error('PDF 미리보기 실패:', error)
- toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 다운로드 기능
- const downloadPdf = () => {
- if (!generatedPdfBuffer) {
- toast.error('다운로드할 PDF가 없습니다.')
- return
- }
-
- const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
-
- const link = document.createElement('a')
- link.href = pdfUrl
- link.download = `contract_${contractId}_${Date.now()}.pdf`
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
-
- URL.revokeObjectURL(pdfUrl)
- toast.success('PDF가 다운로드되었습니다.')
- }
-
- // PDF 미리보기 닫기
- const closePdfPreview = () => {
- console.log("🔄 PDF 미리보기 닫기 시작")
- if (pdfViewerInstance) {
- try {
- console.log("🔄 WebViewer 인스턴스 정리")
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 컨테이너 정리
- const previewDiv = document.getElementById('pdf-preview-container')
- if (previewDiv) {
- try {
- previewDiv.innerHTML = ''
- } catch (error) {
- console.warn('컨테이너 정리 중 오류:', error)
- }
- }
-
- setIsPdfPreviewVisible(false)
- console.log("🔄 PDF 미리보기 닫기 완료")
- }
-
- // 최종 전송
- const handleFinalSubmit = async () => {
- if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
- toast.error('생성된 PDF가 필요합니다.')
- return
- }
-
- if (!userId) {
- toast.error('로그인이 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // 기본계약서 생성 (최종 전송 시점에)
- let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = [];
-
- const contractsToGenerate = selectedBasicContracts.filter(c => c.checked);
- if (contractsToGenerate.length > 0) {
- // vendorId 조회
- let vendorId: number | undefined;
- try {
- const basicInfoData = await getBasicInfo(contractId);
- if (basicInfoData && basicInfoData.success && basicInfoData.data) {
- vendorId = basicInfoData.data.vendorId;
- }
- } catch (error) {
- console.error('vendorId 조회 실패:', error);
- }
-
- if (vendorId) {
- toast.info('기본계약서를 생성하는 중입니다...');
-
- for (const contract of contractsToGenerate) {
- try {
- const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName);
- generatedBasicContractPdfs.push({
- key: `${vendorId}_${contract.type}_${contract.templateName}`,
- ...pdf
- });
- } catch (error) {
- console.error(`${contract.type} 계약서 생성 실패:`, error);
- // 개별 실패는 전체를 중단하지 않음
- }
- }
-
- if (generatedBasicContractPdfs.length > 0) {
- toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`);
- }
- }
- }
-
- // 서버액션을 사용하여 계약승인요청 전송
- const result = await sendContractApprovalRequest(
- contractSummary,
- generatedPdfBuffer,
- 'contractDocument',
- userId,
- generatedBasicContractPdfs
- )
-
- if (result.success) {
- toast.success('계약승인요청이 전송되었습니다.')
- onOpenChange(false)
- } else {
- // 서버에서 이미 처리된 에러 메시지 표시
- toast.error(result.error || '계약승인요청 전송 실패')
- return
- }
- } catch (error: any) {
- console.error('Error submitting approval request:', error)
-
- // 데이터베이스 중복 키 오류 처리
- if (error.message && error.message.includes('duplicate key value violates unique constraint')) {
- toast.error('이미 존재하는 계약번호입니다. 다른 계약번호를 사용해주세요.')
- return
- }
-
- // 다른 오류에 대한 일반적인 처리
- toast.error('계약승인요청 전송 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
-
- // 다이얼로그가 열릴 때 1단계 데이터 수집
- useEffect(() => {
- if (open && currentStep === 1) {
- collectContractSummary()
- }
- }, [open, currentStep, collectContractSummary])
-
- // 계약 요약이 준비되면 기본계약 초기화
- useEffect(() => {
- if (contractSummary && currentStep === 2) {
- const loadBasicContracts = async () => {
- await initializeBasicContracts()
- }
- loadBasicContracts()
- }
- }, [contractSummary, currentStep, initializeBasicContracts])
-
- // 다이얼로그가 닫힐 때 PDF 뷰어 정리
- useEffect(() => {
- if (!open) {
- closePdfPreview()
- }
- }, [open])
-
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5" />
- 계약승인요청
- </DialogTitle>
- </DialogHeader>
-
- <Tabs value={currentStep.toString()} className="w-full">
- <TabsList className="grid w-full grid-cols-3">
- <TabsTrigger value="1" disabled={currentStep < 1}>
- 1. 계약 현황 정리
- </TabsTrigger>
- <TabsTrigger value="2" disabled={currentStep < 2}>
- 2. 기본계약 체크
- </TabsTrigger>
- <TabsTrigger value="3" disabled={currentStep < 3}>
- 3. PDF 미리보기
- </TabsTrigger>
- </TabsList>
-
- {/* 1단계: 계약 현황 정리 */}
- <TabsContent value="1" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <CheckCircle className="h-5 w-5 text-green-600" />
- 작성된 계약 현황
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoading ? (
- <div className="text-center py-4">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
- <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {/* 기본 정보 (필수) */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">기본 정보</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')}
- </div>
- <div>
- <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')}
- </div>
- <div>
- <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')}
- </div>
- <div>
- <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')}
- </div>
- <div>
- <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')}
- </div>
- <div>
- <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')}
- </div>
- <div>
- <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
- </div>
- <div>
- <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
- </div>
- <div>
- <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')}
- </div>
- <div>
- <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')}
- </div>
- <div>
- <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
- </div>
- <div>
- <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
- </div>
- </div>
- </div>
-
- {/* 지급/인도 조건 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">지급/인도 조건</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')}
- </div>
- <div>
- <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')}
- </div>
- <div>
- <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')}
- </div>
- <div>
- <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')}
- </div>
- <div>
- <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')}
- </div>
- <div>
- <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')}
- </div>
- <div>
- <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
- </div>
- <div>
- <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
- </div>
- </div>
- </div>
-
- {/* 추가 조건 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">추가 조건</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')}
- </div>
- <div>
- <span className="font-medium">계약성립조건:</span>
- {contractSummary?.basicInfo?.contractEstablishmentConditions &&
- Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, unknown>)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
- </div>
- <div>
- <span className="font-medium">계약해지조건:</span>
- {contractSummary?.basicInfo?.contractTerminationConditions &&
- Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, unknown>)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
- </div>
- </div>
- </div>
-
- {/* 품목 정보 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="items-enabled"
- checked={contractSummary?.items && contractSummary.items.length > 0}
- disabled
- />
- <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label>
- <Badge variant="outline">선택</Badge>
- </div>
- {contractSummary?.items && contractSummary.items.length > 0 ? (
- <div className="space-y-2">
- <p className="text-sm text-muted-foreground">
- 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
- </p>
- <div className="max-h-32 overflow-y-auto">
- {contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => (
- <div key={index} className="text-xs bg-gray-50 p-2 rounded">
- <div className="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
- <div className="text-muted-foreground">
- 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
- </div>
- </div>
- ))}
- {contractSummary.items.length > 3 && (
- <div className="text-xs text-muted-foreground text-center">
- ... 외 {contractSummary.items.length - 3}개 품목
- </div>
- )}
- </div>
- </div>
- ) : (
- <p className="text-sm text-muted-foreground">
- 품목 정보가 입력되지 않았습니다.
- </p>
- )}
- </div>
-
- {/* 하도급 체크리스트 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="subcontract-enabled"
- checked={!!contractSummary?.subcontractChecklist}
- disabled
- />
- <Label htmlFor="subcontract-enabled" className="font-medium">
- 하도급 체크리스트
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.subcontractChecklist
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-end">
- <Button
- onClick={() => setCurrentStep(2)}
- disabled={isLoading}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 2단계: 기본계약 체크 */}
- <TabsContent value="2" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5 text-blue-600" />
- 기본계약서 선택
- </CardTitle>
- <p className="text-sm text-muted-foreground">
- 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.)
- </p>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoadingBasicContracts ? (
- <div className="text-center py-8">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
- <p className="mt-2 text-sm text-muted-foreground">기본계약 템플릿을 불러오는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {selectedBasicContracts.length > 0 ? (
- <div className="space-y-3">
- <div className="flex items-center justify-between">
- <h4 className="font-medium">필요한 기본계약서</h4>
- <Badge variant="outline">
- {selectedBasicContracts.filter(c => c.checked).length}개 선택됨
- </Badge>
- </div>
-
- <div className="grid gap-3">
- {selectedBasicContracts.map((contract) => (
- <div
- key={contract.type}
- className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50"
- >
- <div className="flex items-center gap-3">
- <Checkbox
- id={`contract-${contract.type}`}
- checked={contract.checked}
- onCheckedChange={() => toggleBasicContract(contract.type)}
- />
- <div>
- <Label
- htmlFor={`contract-${contract.type}`}
- className="font-medium cursor-pointer"
- >
- {contract.type}
- </Label>
- <p className="text-sm text-muted-foreground">
- 템플릿: {contract.templateName}
- </p>
- </div>
- </div>
- <Badge
- variant="secondary"
- className="text-xs"
- >
- {contract.checked ? "선택됨" : "미선택"}
- </Badge>
- </div>
- ))}
- </div>
-
- </div>
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
- <p>기본계약서 목록을 불러올 수 없습니다.</p>
- <p className="text-sm">잠시 후 다시 시도해주세요.</p>
- </div>
- )}
-
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(1)}>
- 이전 단계
- </Button>
- <Button
- onClick={() => setCurrentStep(3)}
- disabled={isLoadingBasicContracts}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 3단계: PDF 미리보기 */}
- <TabsContent value="3" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Eye className="h-5 w-5 text-purple-600" />
- PDF 미리보기
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {!generatedPdfUrl ? (
- <div className="text-center py-8">
- <Button onClick={generatePdf} disabled={isLoading}>
- {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
- </Button>
- </div>
- ) : (
- <div className="space-y-4">
- <div className="border rounded-lg p-4 bg-green-50">
- <div className="flex items-center gap-2">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="font-medium text-green-900">PDF 생성 완료</span>
- </div>
- </div>
-
- <div className="border rounded-lg p-4">
- <div className="flex items-center justify-between mb-4">
- <h4 className="font-medium">생성된 PDF</h4>
- <div className="flex gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={downloadPdf}
- disabled={isLoading}
- >
- <Download className="h-4 w-4 mr-2" />
- 다운로드
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={openPdfPreview}
- disabled={isLoading}
- >
- <Eye className="h-4 w-4 mr-2" />
- 미리보기
- </Button>
- </div>
- </div>
-
- {/* PDF 미리보기 영역 */}
- <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container>
- {isPdfPreviewVisible ? (
- <>
- <div className="absolute top-2 right-2 z-10">
- <Button
- variant="outline"
- size="sm"
- onClick={closePdfPreview}
- className="bg-white/90 hover:bg-white"
- >
- ✕ 닫기
- </Button>
- </div>
- <div id="pdf-preview-container" className="w-full h-full" />
- </>
- ) : (
- <div className="flex items-center justify-center h-full">
- <div className="text-center text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-2" />
- <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p>
- </div>
- </div>
- )}
- </div>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(2)}>
- 이전 단계
- </Button>
- <Button
- onClick={handleFinalSubmit}
- disabled={!generatedPdfUrl || isLoading}
- className="bg-green-600 hover:bg-green-700"
- >
- <Send className="h-4 w-4 mr-2" />
- {isLoading ? '전송 중...' : '최종 전송'}
- </Button>
- </div>
- </TabsContent>
- </Tabs>
- </DialogContent>
- </Dialog>
+'use client' + +import React, { useState, useEffect } from 'react' +import { useSession } from 'next-auth/react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Input } from '@/components/ui/input' +import { toast } from 'sonner' +import { + FileText, + Upload, + Eye, + Send, + CheckCircle, + Download, + AlertCircle +} from 'lucide-react' +import { ContractDocuments } from './general-contract-documents' +import { getActiveContractTemplates } from '@/lib/bidding/service' +import { type BasicContractTemplate } from '@/db/schema' +import { + getBasicInfo, + getContractItems, + getSubcontractChecklist, + uploadContractApprovalFile, + sendContractApprovalRequest, + getContractById, + getContractTemplateByContractType, + getStorageInfo +} from '../service' +import { mapContractDataToTemplateVariables } from '../utils' +import { ApprovalPreviewDialog } from '@/lib/approval/client' +import { requestContractApprovalWithApproval } from '../approval-actions' +import { mapContractToApprovalTemplateVariables } from '../approval-template-variables' + +interface ContractApprovalRequestDialogProps { + contract: Record<string, unknown> + open: boolean + onOpenChange: (open: boolean) => void +} + +interface ContractSummary { + basicInfo: Record<string, unknown> + items: Record<string, unknown>[] + subcontractChecklist: Record<string, unknown> | null + storageInfo?: Record<string, unknown>[] + pdfPath?: string + basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }> +} + +export function ContractApprovalRequestDialog({ + contract, + open, + onOpenChange +}: ContractApprovalRequestDialogProps) { + const { data: session } = useSession() + const [currentStep, setCurrentStep] = useState(1) + const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null) + const [uploadedFile, setUploadedFile] = useState<File | null>(null) + const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null) + const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null) + const [isLoading, setIsLoading] = useState(false) + const [pdfViewerInstance, setPdfViewerInstance] = useState<any>(null) + const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false) + + // 기본계약 관련 상태 + const [selectedBasicContracts, setSelectedBasicContracts] = useState<Array<{ + type: string; + templateName: string; + checked: boolean; + }>>([]) + const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false) + + // 결재 관련 상태 + const [approvalDialogOpen, setApprovalDialogOpen] = useState(false) + const [approvalVariables, setApprovalVariables] = useState<Record<string, string>>({}) + const [savedPdfPath, setSavedPdfPath] = useState<string | null>(null) + const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState<Array<{ key: string; buffer: number[]; fileName: string }>>([]) + + const contractId = contract.id as number + const userId = session?.user?.id || '' + + + // 기본계약 생성 함수 (최종 전송 시점에 호출) + const generateBasicContractPdf = async ( + vendorId: number, + contractType: string, + templateName: string + ): Promise<{ buffer: number[], fileName: string }> => { + try { + // 1. 템플릿 데이터 준비 (서버 액션 호출) + const prepareResponse = await fetch("/api/contracts/prepare-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + templateName, + vendorId, + }), + }); + + if (!prepareResponse.ok) { + const errorText = await prepareResponse.text(); + throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`); + } + + const { template, templateData } = await prepareResponse.json(); + + // 2. 템플릿 파일 다운로드 + const templateResponse = await fetch("/api/contracts/get-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ templatePath: template.filePath }), + }); + + const templateBlob = await templateResponse.blob(); + const templateFile = new window.File([templateBlob], "template.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + + // 3. PDFTron WebViewer로 PDF 변환 + const { default: WebViewer } = await import("@pdftron/webviewer"); + + const tempDiv = document.createElement('div'); + tempDiv.style.display = 'none'; + document.body.appendChild(tempDiv); + + try { + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + }, + tempDiv + ); + + const { Core } = instance; + const { createDocument } = Core; + + const templateDoc = await createDocument(templateFile, { + filename: templateFile.name, + extension: 'docx', + }); + + // 변수 치환 적용 + await templateDoc.applyTemplateValues(templateData); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const fileData = await templateDoc.getFileData(); + const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' }); + + const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`; + + instance.UI.dispose(); + return { + buffer: Array.from(pdfBuffer), + fileName + }; + + } finally { + if (tempDiv.parentNode) { + document.body.removeChild(tempDiv); + } + } + + } catch (error) { + console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error); + throw error; + } + }; + + // 기본계약 생성 및 선택 초기화 + const initializeBasicContracts = React.useCallback(async () => { + if (!contractSummary?.basicInfo) return; + + setIsLoadingBasicContracts(true); + try { + // 기본적으로 사용할 수 있는 계약서 타입들 + const availableContracts: Array<{ + type: string; + templateName: string; + checked: boolean; + }> = [ + { type: "NDA", templateName: "비밀", checked: false }, + { type: "General_GTC", templateName: "General GTC", checked: false }, + { type: "기술자료", templateName: "기술", checked: false } + ]; + + // 프로젝트 코드가 있으면 Project GTC도 추가 + if (contractSummary.basicInfo.projectCode) { + availableContracts.push({ + type: "Project_GTC", + templateName: contractSummary.basicInfo.projectCode as string, + checked: false + }); + } + + setSelectedBasicContracts(availableContracts); + } catch (error) { + console.error('기본계약 초기화 실패:', error); + toast.error('기본계약 초기화에 실패했습니다.'); + } finally { + setIsLoadingBasicContracts(false); + } + }, [contractSummary]); + + // 기본계약 선택 토글 + const toggleBasicContract = (type: string) => { + setSelectedBasicContracts(prev => + prev.map(contract => + contract.type === type + ? { ...contract, checked: !contract.checked } + : contract + ) + ); + }; + + + // 1단계: 계약 현황 수집 + const collectContractSummary = React.useCallback(async () => { + setIsLoading(true) + try { + // 각 컴포넌트에서 활성화된 데이터만 수집 + const summary: ContractSummary = { + basicInfo: {}, + items: [], + subcontractChecklist: null + } + + // Basic Info 확인 (항상 활성화) + try { + const basicInfoData = await getBasicInfo(contractId) + if (basicInfoData && basicInfoData.success) { + summary.basicInfo = basicInfoData.data || {} + } + // externalYardEntry 정보도 추가로 가져오기 + const contractData = await getContractById(contractId) + if (contractData) { + summary.basicInfo = { + ...summary.basicInfo, + externalYardEntry: contractData.externalYardEntry || 'N' + } + } + } catch { + console.log('Basic Info 데이터 없음') + } + + // 품목 정보 확인 + try { + const itemsData = await getContractItems(contractId) + if (itemsData && itemsData.length > 0) { + summary.items = itemsData + } + } catch { + console.log('품목 정보 데이터 없음') + } + + try { + // Subcontract Checklist 확인 + const subcontractData = await getSubcontractChecklist(contractId) + if (subcontractData && subcontractData.success && subcontractData.enabled) { + summary.subcontractChecklist = subcontractData.data + } + } catch { + console.log('Subcontract Checklist 데이터 없음') + } + + // 임치(물품보관) 계약 정보 확인 (SG) + try { + if (summary.basicInfo?.contractType === 'SG') { + const storageData = await getStorageInfo(contractId) + if (storageData && storageData.length > 0) { + summary.storageInfo = storageData + } + } + } catch { + console.log('임치계약 정보 없음') + } + + console.log('contractSummary 구조:', summary) + console.log('basicInfo 내용:', summary.basicInfo) + setContractSummary(summary) + } catch (error) { + console.error('Error collecting contract summary:', error) + toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + }, [contractId]) + + // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드 + const generatePdf = async () => { + if (!contractSummary) { + toast.error('계약 정보가 필요합니다.') + return + } + + setIsLoading(true) + try { + // 1. 계약 유형에 맞는 템플릿 조회 + const contractType = contractSummary.basicInfo.contractType as string + const templateResult = await getContractTemplateByContractType(contractType) + + if (!templateResult.success || !templateResult.template) { + throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.') + } + + const template = templateResult.template + + // 2. 템플릿 파일 다운로드 + const templateResponse = await fetch("/api/contracts/get-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ templatePath: template.filePath }), + }) + + if (!templateResponse.ok) { + throw new Error("템플릿 파일을 다운로드할 수 없습니다.") + } + + const templateBlob = await templateResponse.blob() + const templateFile = new File([templateBlob], template.fileName || "template.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }) + + // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환 + // @ts-ignore + const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer) + + // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음) + const tempDiv = document.createElement('div') + tempDiv.style.display = 'none' + document.body.appendChild(tempDiv) + + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + tempDiv + ) + + try { + const { Core } = instance + const { createDocument } = Core + + // 템플릿 문서 생성 및 변수 치환 + const templateDoc = await createDocument(templateFile, { + filename: templateFile.name, + extension: 'docx', + }) + + // 템플릿 변수 매핑 + const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary) + + console.log("🔄 변수 치환 시작:", mappedTemplateData) + await templateDoc.applyTemplateValues(mappedTemplateData as any) + console.log("✅ 변수 치환 완료") + + // PDF 변환 + const fileData = await templateDoc.getFileData() + const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' }) + + console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`) + + // PDF 버퍼를 Blob URL로 변환하여 미리보기 + const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' }) + const pdfUrl = URL.createObjectURL(pdfBlob) + setGeneratedPdfUrl(pdfUrl) + + // PDF 버퍼를 상태에 저장 (최종 전송 시 사용) + setGeneratedPdfBuffer(new Uint8Array(pdfBuffer)) + + toast.success('PDF가 생성되었습니다.') + + } finally { + // 임시 WebViewer 정리 + instance.UI.dispose() + document.body.removeChild(tempDiv) + } + + } catch (error: any) { + console.error('❌ PDF 생성 실패:', error) + const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류') + toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`) + } finally { + setIsLoading(false) + } + } + + // PDF 미리보기 기능 + const openPdfPreview = async () => { + if (!generatedPdfBuffer) { + toast.error('생성된 PDF가 없습니다.') + return + } + + setIsLoading(true) + try { + // @ts-ignore + const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer) + + // 기존 인스턴스가 있다면 정리 + if (pdfViewerInstance) { + console.log("🔄 기존 WebViewer 인스턴스 정리") + try { + pdfViewerInstance.UI.dispose() + } catch (error) { + console.warn('기존 WebViewer 정리 중 오류:', error) + } + setPdfViewerInstance(null) + } + + // 미리보기용 컨테이너 확인 + let previewDiv = document.getElementById('pdf-preview-container') + if (!previewDiv) { + console.log("🔄 컨테이너 생성") + previewDiv = document.createElement('div') + previewDiv.id = 'pdf-preview-container' + previewDiv.className = 'w-full h-full' + previewDiv.style.width = '100%' + previewDiv.style.height = '100%' + + // 실제 컨테이너에 추가 + const actualContainer = document.querySelector('[data-pdf-container]') + if (actualContainer) { + actualContainer.appendChild(previewDiv) + } + } + + console.log("🔄 WebViewer 인스턴스 생성 시작") + + // WebViewer 인스턴스 생성 (문서 없이) + const instance = await Promise.race([ + WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + previewDiv + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000) + ) + ]) + + console.log("🔄 WebViewer 인스턴스 생성 완료") + setPdfViewerInstance(instance) + + // PDF 버퍼를 Blob으로 변환 + const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' }) + const pdfUrl = URL.createObjectURL(pdfBlob) + console.log("🔄 PDF Blob URL 생성:", pdfUrl) + + // 문서 로드 + console.log("🔄 문서 로드 시작") + const { documentViewer } = (instance as any).Core + + // 문서 로드 이벤트 대기 + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('문서 로드 타임아웃')) + }, 20000) + + const onDocumentLoaded = () => { + clearTimeout(timeout) + documentViewer.removeEventListener('documentLoaded', onDocumentLoaded) + documentViewer.removeEventListener('documentError', onDocumentError) + console.log("🔄 문서 로드 완료") + resolve(true) + } + + const onDocumentError = (error: any) => { + clearTimeout(timeout) + documentViewer.removeEventListener('documentLoaded', onDocumentLoaded) + documentViewer.removeEventListener('documentError', onDocumentError) + console.error('문서 로드 오류:', error) + reject(error) + } + + documentViewer.addEventListener('documentLoaded', onDocumentLoaded) + documentViewer.addEventListener('documentError', onDocumentError) + + // 문서 로드 시작 + documentViewer.loadDocument(pdfUrl) + }) + + setIsPdfPreviewVisible(true) + toast.success('PDF 미리보기가 준비되었습니다.') + + } catch (error) { + console.error('PDF 미리보기 실패:', error) + toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`) + } finally { + setIsLoading(false) + } + } + + // PDF 다운로드 기능 + const downloadPdf = () => { + if (!generatedPdfBuffer) { + toast.error('다운로드할 PDF가 없습니다.') + return + } + + const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' }) + const pdfUrl = URL.createObjectURL(pdfBlob) + + const link = document.createElement('a') + link.href = pdfUrl + link.download = `contract_${contractId}_${Date.now()}.pdf` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + URL.revokeObjectURL(pdfUrl) + toast.success('PDF가 다운로드되었습니다.') + } + + // PDF 미리보기 닫기 + const closePdfPreview = () => { + console.log("🔄 PDF 미리보기 닫기 시작") + if (pdfViewerInstance) { + try { + console.log("🔄 WebViewer 인스턴스 정리") + pdfViewerInstance.UI.dispose() + } catch (error) { + console.warn('WebViewer 정리 중 오류:', error) + } + setPdfViewerInstance(null) + } + + // 컨테이너 정리 + const previewDiv = document.getElementById('pdf-preview-container') + if (previewDiv) { + try { + previewDiv.innerHTML = '' + } catch (error) { + console.warn('컨테이너 정리 중 오류:', error) + } + } + + setIsPdfPreviewVisible(false) + console.log("🔄 PDF 미리보기 닫기 완료") + } + + // PDF를 서버에 저장하는 함수 (API route 사용) + const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise<string | null> => { + try { + // PDF 버퍼를 Blob으로 변환 + const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' }); + + // FormData 생성 + const formData = new FormData(); + formData.append('file', pdfBlob, fileName); + formData.append('contractId', String(contractId)); + + // API route로 업로드 + const response = await fetch('/api/general-contracts/upload-pdf', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.'); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.'); + } + + return result.filePath; + } catch (error) { + console.error('PDF 저장 실패:', error); + return null; + } + }; + + // 최종 전송 - 결재 프로세스 시작 + const handleFinalSubmit = async () => { + if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) { + toast.error('생성된 PDF가 필요합니다.') + return + } + + if (!userId) { + toast.error('로그인이 필요합니다.') + return + } + + setIsLoading(true) + try { + // 기본계약서 생성 (최종 전송 시점에) + let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = []; + + const contractsToGenerate = selectedBasicContracts.filter(c => c.checked); + if (contractsToGenerate.length > 0) { + // vendorId 조회 + let vendorId: number | undefined; + try { + const basicInfoData = await getBasicInfo(contractId); + if (basicInfoData && basicInfoData.success && basicInfoData.data) { + vendorId = basicInfoData.data.vendorId; + } + } catch (error) { + console.error('vendorId 조회 실패:', error); + } + + if (vendorId) { + toast.info('기본계약서를 생성하는 중입니다...'); + + for (const contract of contractsToGenerate) { + try { + const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName); + generatedBasicContractPdfs.push({ + key: `${vendorId}_${contract.type}_${contract.templateName}`, + ...pdf + }); + } catch (error) { + console.error(`${contract.type} 계약서 생성 실패:`, error); + // 개별 실패는 전체를 중단하지 않음 + } + } + + if (generatedBasicContractPdfs.length > 0) { + toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`); + } + } + } + + // PDF를 서버에 저장 + toast.info('PDF를 서버에 저장하는 중입니다...'); + const pdfPath = await savePdfToServer( + generatedPdfBuffer, + `contract_${contractId}_${Date.now()}.pdf` + ); + + if (!pdfPath) { + toast.error('PDF 저장에 실패했습니다.'); + return; + } + + setSavedPdfPath(pdfPath); + setSavedBasicContractPdfs(generatedBasicContractPdfs); + + // 결재 템플릿 변수 매핑 + const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary); + setApprovalVariables(approvalVars); + + // 계약승인요청 dialog close + onOpenChange(false); + + // 결재 템플릿 dialog open + setApprovalDialogOpen(true); + } catch (error: any) { + console.error('Error preparing approval:', error); + toast.error('결재 준비 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 결재 등록 처리 + const handleApprovalSubmit = async (data: { + approvers: string[]; + title: string; + attachments?: File[]; + }) => { + if (!contractSummary || !savedPdfPath) { + toast.error('계약 정보가 필요합니다.') + return + } + + setIsLoading(true) + try { + const result = await requestContractApprovalWithApproval({ + contractId, + contractSummary: { + ...contractSummary, + // PDF 경로를 contractSummary에 추가 + pdfPath: savedPdfPath || undefined, + basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined, + } as ContractSummary, + currentUser: { + id: Number(userId), + epId: session?.user?.epId || null, + email: session?.user?.email || undefined, + }, + approvers: data.approvers, + title: data.title, + }); + + if (result.status === 'pending_approval') { + toast.success('결재가 등록되었습니다.') + setApprovalDialogOpen(false); + } else { + toast.error('결재 등록에 실패했습니다.') + } + } catch (error: any) { + console.error('Error submitting approval:', error); + toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`); + } finally { + setIsLoading(false) + } + } + + // 다이얼로그가 열릴 때 1단계 데이터 수집 + useEffect(() => { + if (open && currentStep === 1) { + collectContractSummary() + } + }, [open, currentStep, collectContractSummary]) + + // 계약 요약이 준비되면 기본계약 초기화 + useEffect(() => { + if (contractSummary && currentStep === 2) { + const loadBasicContracts = async () => { + await initializeBasicContracts() + } + loadBasicContracts() + } + }, [contractSummary, currentStep, initializeBasicContracts]) + + // 다이얼로그가 닫힐 때 PDF 뷰어 정리 + useEffect(() => { + if (!open) { + closePdfPreview() + } + }, [open]) + + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + 계약승인요청 + </DialogTitle> + </DialogHeader> + + <Tabs value={currentStep.toString()} className="w-full"> + <TabsList className="grid w-full grid-cols-3"> + <TabsTrigger value="1" disabled={currentStep < 1}> + 1. 계약 현황 정리 + </TabsTrigger> + <TabsTrigger value="2" disabled={currentStep < 2}> + 2. 기본계약 체크 + </TabsTrigger> + <TabsTrigger value="3" disabled={currentStep < 3}> + 3. PDF 미리보기 + </TabsTrigger> + </TabsList> + + {/* 1단계: 계약 현황 정리 */} + <TabsContent value="1" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <CheckCircle className="h-5 w-5 text-green-600" /> + 작성된 계약 현황 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {isLoading ? ( + <div className="text-center py-4"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div> + <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p> + </div> + ) : ( + <div className="space-y-4"> + {/* 기본 정보 (필수) */} + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <Label className="font-medium">기본 정보</Label> + <Badge variant="secondary">필수</Badge> + </div> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')} + </div> + <div> + <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')} + </div> + <div> + <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')} + </div> + <div> + <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')} + </div> + <div> + <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')} + </div> + <div> + <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')} + </div> + <div> + <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')} + </div> + <div> + <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')} + </div> + <div> + <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')} + </div> + <div> + <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')} + </div> + <div> + <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')} + </div> + <div> + <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')} + </div> + </div> + </div> + + {/* 지급/인도 조건 */} + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <Label className="font-medium">지급/인도 조건</Label> + <Badge variant="secondary">필수</Badge> + </div> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')} + </div> + <div> + <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')} + </div> + <div> + <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')} + </div> + <div> + <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')} + </div> + <div> + <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')} + </div> + <div> + <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')} + </div> + <div> + <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')} + </div> + <div> + <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'} + </div> + </div> + </div> + + {/* 추가 조건 */} + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <Label className="font-medium">추가 조건</Label> + <Badge variant="secondary">필수</Badge> + </div> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')} + </div> + <div> + <span className="font-medium">계약성립조건:</span> + {contractSummary?.basicInfo?.contractEstablishmentConditions ? (() => { + const conditions = Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, boolean>) + .filter(([, value]) => value === true) + .map(([key]) => { + const conditionMap: Record<string, string> = { + 'ownerApproval': '정규업체 등록(실사 포함) 시', + 'regularVendorRegistration': '프로젝트 수주 시', + 'shipOwnerApproval': '선주 승인 시', + 'other': '기타' + }; + return conditionMap[key] || key; + }); + return conditions.length > 0 ? conditions.join(', ') : '없음'; + })() : '없음'} + </div> + <div> + <span className="font-medium">계약해지조건:</span> + {contractSummary?.basicInfo?.contractTerminationConditions ? (() => { + const conditions = Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, boolean>) + .filter(([, value]) => value === true) + .map(([key]) => { + const conditionMap: Record<string, string> = { + 'standardTermination': '표준 계약해지조건', + 'projectNotAwarded': '프로젝트 미수주 시', + 'other': '기타' + }; + return conditionMap[key] || key; + }); + return conditions.length > 0 ? conditions.join(', ') : '없음'; + })() : '없음'} + </div> + </div> + </div> + + {/* 품목 정보 */} + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <Checkbox + id="items-enabled" + checked={contractSummary?.items && contractSummary.items.length > 0} + disabled + /> + <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label> + <Badge variant="outline">선택</Badge> + </div> + {contractSummary?.items && contractSummary.items.length > 0 ? ( + <div className="space-y-2"> + <p className="text-sm text-muted-foreground"> + 총 {contractSummary.items.length}개 품목이 입력되어 있습니다. + </p> + <div className="max-h-32 overflow-y-auto"> + {contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => ( + <div key={index} className="text-xs bg-gray-50 p-2 rounded"> + <div className="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div> + <div className="text-muted-foreground"> + 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)} + </div> + </div> + ))} + {contractSummary.items.length > 3 && ( + <div className="text-xs text-muted-foreground text-center"> + ... 외 {contractSummary.items.length - 3}개 품목 + </div> + )} + </div> + </div> + ) : ( + <p className="text-sm text-muted-foreground"> + 품목 정보가 입력되지 않았습니다. + </p> + )} + </div> + + {/* 하도급 체크리스트 */} + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <Checkbox + id="subcontract-enabled" + checked={!!contractSummary?.subcontractChecklist} + disabled + /> + <Label htmlFor="subcontract-enabled" className="font-medium"> + 하도급 체크리스트 + </Label> + <Badge variant="outline">선택</Badge> + </div> + <p className="text-sm text-muted-foreground"> + {contractSummary?.subcontractChecklist + ? '정보가 입력되어 있습니다.' + : '정보가 입력되지 않았습니다.'} + </p> + </div> + </div> + )} + </CardContent> + </Card> + + <div className="flex justify-end"> + <Button + onClick={() => setCurrentStep(2)} + disabled={isLoading} + > + 다음 단계 + </Button> + </div> + </TabsContent> + + {/* 2단계: 기본계약 체크 */} + <TabsContent value="2" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5 text-blue-600" /> + 기본계약서 선택 + </CardTitle> + <p className="text-sm text-muted-foreground"> + 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.) + </p> + </CardHeader> + <CardContent className="space-y-4"> + {isLoadingBasicContracts ? ( + <div className="text-center py-8"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div> + <p className="mt-2 text-sm text-muted-foreground">기본계약 템플릿을 불러오는 중...</p> + </div> + ) : ( + <div className="space-y-4"> + {selectedBasicContracts.length > 0 ? ( + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <h4 className="font-medium">필요한 기본계약서</h4> + <Badge variant="outline"> + {selectedBasicContracts.filter(c => c.checked).length}개 선택됨 + </Badge> + </div> + + <div className="grid gap-3"> + {selectedBasicContracts.map((contract) => ( + <div + key={contract.type} + className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50" + > + <div className="flex items-center gap-3"> + <Checkbox + id={`contract-${contract.type}`} + checked={contract.checked} + onCheckedChange={() => toggleBasicContract(contract.type)} + /> + <div> + <Label + htmlFor={`contract-${contract.type}`} + className="font-medium cursor-pointer" + > + {contract.type} + </Label> + <p className="text-sm text-muted-foreground"> + 템플릿: {contract.templateName} + </p> + </div> + </div> + <Badge + variant="secondary" + className="text-xs" + > + {contract.checked ? "선택됨" : "미선택"} + </Badge> + </div> + ))} + </div> + + </div> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>기본계약서 목록을 불러올 수 없습니다.</p> + <p className="text-sm">잠시 후 다시 시도해주세요.</p> + </div> + )} + + </div> + )} + </CardContent> + </Card> + + <div className="flex justify-between"> + <Button variant="outline" onClick={() => setCurrentStep(1)}> + 이전 단계 + </Button> + <Button + onClick={() => setCurrentStep(3)} + disabled={isLoadingBasicContracts} + > + 다음 단계 + </Button> + </div> + </TabsContent> + + {/* 3단계: PDF 미리보기 */} + <TabsContent value="3" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Eye className="h-5 w-5 text-purple-600" /> + PDF 미리보기 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {!generatedPdfUrl ? ( + <div className="text-center py-8"> + <Button onClick={generatePdf} disabled={isLoading}> + {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'} + </Button> + </div> + ) : ( + <div className="space-y-4"> + <div className="border rounded-lg p-4 bg-green-50"> + <div className="flex items-center gap-2"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <span className="font-medium text-green-900">PDF 생성 완료</span> + </div> + </div> + + <div className="border rounded-lg p-4"> + <div className="flex items-center justify-between mb-4"> + <h4 className="font-medium">생성된 PDF</h4> + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + onClick={downloadPdf} + disabled={isLoading} + > + <Download className="h-4 w-4 mr-2" /> + 다운로드 + </Button> + <Button + variant="outline" + size="sm" + onClick={openPdfPreview} + disabled={isLoading} + > + <Eye className="h-4 w-4 mr-2" /> + 미리보기 + </Button> + </div> + </div> + + {/* PDF 미리보기 영역 */} + <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container> + {isPdfPreviewVisible ? ( + <> + <div className="absolute top-2 right-2 z-10"> + <Button + variant="outline" + size="sm" + onClick={closePdfPreview} + className="bg-white/90 hover:bg-white" + > + ✕ 닫기 + </Button> + </div> + <div id="pdf-preview-container" className="w-full h-full" /> + </> + ) : ( + <div className="flex items-center justify-center h-full"> + <div className="text-center text-muted-foreground"> + <FileText className="h-12 w-12 mx-auto mb-2" /> + <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p> + </div> + </div> + )} + </div> + </div> + </div> + )} + </CardContent> + </Card> + + <div className="flex justify-between"> + <Button variant="outline" onClick={() => setCurrentStep(2)}> + 이전 단계 + </Button> + <Button + onClick={handleFinalSubmit} + disabled={!generatedPdfUrl || isLoading} + className="bg-green-600 hover:bg-green-700" + > + <Send className="h-4 w-4 mr-2" /> + {isLoading ? '전송 중...' : '최종 전송'} + </Button> + </div> + </TabsContent> + </Tabs> + </DialogContent> + + {/* 결재 미리보기 Dialog */} + {session?.user && session.user.epId && contractSummary && ( + <ApprovalPreviewDialog + open={approvalDialogOpen} + onOpenChange={(open) => { + setApprovalDialogOpen(open); + if (!open) { + setApprovalVariables({}); + setSavedPdfPath(null); + setSavedBasicContractPdfs([]); + } + }} + templateName="일반계약 결재" + variables={approvalVariables} + title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handleApprovalSubmit} + enableAttachments={false} + /> + )} + </Dialog> )}
\ No newline at end of file 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..be174417 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -30,6 +30,9 @@ 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' +import { cn } from '@/lib/utils' interface ContractItem { id?: number @@ -41,12 +44,12 @@ interface ContractItem { materialGroupCode?: string materialGroupDescription?: string specification: string - quantity: number + quantity: number | string // number | string으로 변경하여 입력 중 포맷팅 지원 quantityUnit: string - totalWeight: number + totalWeight: number | string // number | string으로 변경하여 입력 중 포맷팅 지원 weightUnit: string contractDeliveryDate: string - contractUnitPrice: number + contractUnitPrice: number | string // number | string으로 변경하여 입력 중 포맷팅 지원 contractAmount: number contractCurrency: string isSelected?: boolean @@ -103,6 +106,34 @@ export function ContractItemsTable({ contractUnitPrice: '' }) + // 천단위 콤마 포맷팅 헬퍼 함수들 + const formatNumberWithCommas = (value: string | number | null | undefined): string => { + if (value === null || value === undefined || value === '') return '' + const str = value.toString() + const parts = str.split('.') + const integerPart = parts[0].replace(/,/g, '') + + // 정수부가 비어있거나 '-' 만 있는 경우 처리 + if (integerPart === '' || integerPart === '-') { + return str + } + + const num = parseFloat(integerPart) + if (isNaN(num)) return str + + const formattedInt = num.toLocaleString() + + if (parts.length > 1) { + return `${formattedInt}.${parts[1]}` + } + + return formattedInt + } + + const parseNumberFromCommas = (value: string): string => { + return value.replace(/,/g, '') + } + // 초기 데이터 로드 React.useEffect(() => { const loadItems = async () => { @@ -123,6 +154,8 @@ export function ContractItemsTable({ } } + // number 타입을 string으로 변환하지 않고 일단 그대로 둠 (렌더링 시 포맷팅) + // 단, 입력 중 편의를 위해 string이 들어올 수 있으므로 ContractItem 타입 변경함 return { id: item.id, projectId: item.projectId || null, @@ -172,11 +205,20 @@ export function ContractItemsTable({ // validation 체크 const errors: string[] = [] - for (let index = 0; index < localItems.length; index++) { - const item = localItems[index] - if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) + // 저장 시 number로 변환된 데이터 준비 + const itemsToSave = localItems.map(item => ({ + ...item, + quantity: parseFloat(item.quantity.toString().replace(/,/g, '')) || 0, + totalWeight: parseFloat(item.totalWeight.toString().replace(/,/g, '')) || 0, + contractUnitPrice: parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0, + contractAmount: parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0, + })); + + for (let index = 0; index < itemsToSave.length; index++) { + const item = itemsToSave[index] + // 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.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`) if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`) } @@ -186,7 +228,7 @@ export function ContractItemsTable({ return } - await updateContractItems(contractId, localItems as any) + await updateContractItems(contractId, itemsToSave as any) toast.success('품목정보가 저장되었습니다.') } catch (error) { console.error('Error saving contract items:', error) @@ -197,9 +239,18 @@ export function ContractItemsTable({ } // 총 금액 계산 - const totalAmount = localItems.reduce((sum, item) => sum + item.contractAmount, 0) - const totalQuantity = localItems.reduce((sum, item) => sum + item.quantity, 0) - const totalUnitPrice = localItems.reduce((sum, item) => sum + item.contractUnitPrice, 0) + const totalAmount = localItems.reduce((sum, item) => { + const amount = parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0 + return sum + amount + }, 0) + const totalQuantity = localItems.reduce((sum, item) => { + const quantity = parseFloat(item.quantity.toString().replace(/,/g, '')) || 0 + return sum + quantity + }, 0) + const totalUnitPrice = localItems.reduce((sum, item) => { + const unitPrice = parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0 + return sum + unitPrice + }, 0) const amountDifference = availableBudget - totalAmount const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0 @@ -211,12 +262,14 @@ export function ContractItemsTable({ // 아이템 업데이트 const updateItem = (index: number, field: keyof ContractItem, value: string | number | boolean | undefined) => { const updatedItems = [...localItems] - updatedItems[index] = { ...updatedItems[index], [field]: value } + const updatedItem = { ...updatedItems[index], [field]: value } + updatedItems[index] = updatedItem // 단가나 수량이 변경되면 금액 자동 계산 if (field === 'contractUnitPrice' || field === 'quantity') { - const item = updatedItems[index] - updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity + const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0 + const unitPrice = parseFloat(updatedItem.contractUnitPrice.toString().replace(/,/g, '')) || 0 + updatedItem.contractAmount = unitPrice * quantity } setLocalItems(updatedItems) @@ -271,6 +324,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) { @@ -296,7 +377,8 @@ export function ContractItemsTable({ if (batchInputData.contractUnitPrice) { updatedItem.contractUnitPrice = parseFloat(batchInputData.contractUnitPrice) || 0 // 단가가 변경되면 계약금액도 재계산 - updatedItem.contractAmount = updatedItem.contractUnitPrice * updatedItem.quantity + const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0 + updatedItem.contractAmount = (parseFloat(batchInputData.contractUnitPrice) || 0) * quantity } return updatedItem @@ -382,6 +464,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 @@ -671,14 +764,23 @@ export function ContractItemsTable({ )} </TableCell> */} <TableCell className="px-3 py-3"> + {readOnly ? ( + <span className="text-sm text-right">{item.quantity.toLocaleString()}</span> + ) : ( <Input - type="number" - value={item.quantity} - onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} + type="text" + value={formatNumberWithCommas(item.quantity)} + onChange={(e) => { + const val = parseNumberFromCommas(e.target.value) + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + updateItem(index, 'quantity', val) + } + }} className="h-8 text-sm text-right" placeholder="0" - disabled={!isEnabled} + disabled={!isEnabled || isQuantityDisabled} /> + )} </TableCell> <TableCell className="px-3 py-3"> {readOnly ? ( @@ -707,9 +809,14 @@ export function ContractItemsTable({ <span className="text-sm text-right">{item.totalWeight.toLocaleString()}</span> ) : ( <Input - type="number" - value={item.totalWeight} - onChange={(e) => updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)} + type="text" + value={formatNumberWithCommas(item.totalWeight)} + onChange={(e) => { + const val = parseNumberFromCommas(e.target.value) + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + updateItem(index, 'totalWeight', val) + } + }} className="h-8 text-sm text-right" placeholder="0" disabled={!isEnabled || isQuantityDisabled} @@ -756,9 +863,14 @@ export function ContractItemsTable({ <span className="text-sm text-right">{item.contractUnitPrice.toLocaleString()}</span> ) : ( <Input - type="number" - value={item.contractUnitPrice} - onChange={(e) => updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)} + type="text" + value={formatNumberWithCommas(item.contractUnitPrice)} + onChange={(e) => { + const val = parseNumberFromCommas(e.target.value) + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + updateItem(index, 'contractUnitPrice', val) + } + }} className="h-8 text-sm text-right" placeholder="0" disabled={!isEnabled} diff --git a/lib/general-contracts/handlers.ts b/lib/general-contracts/handlers.ts new file mode 100644 index 00000000..029fb9cd --- /dev/null +++ b/lib/general-contracts/handlers.ts @@ -0,0 +1,157 @@ +/** + * 일반계약 관련 결재 액션 핸들러 + * + * 실제 비즈니스 로직만 포함 (결재 로직은 approval-workflow에서 처리) + */ + +'use server'; + +import { sendContractApprovalRequest } from './service'; +import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; +import db from '@/db/db'; +import { eq } from 'drizzle-orm'; +import { generalContracts } from '@/db/schema/generalContract'; + +interface ContractSummary { + basicInfo: Record<string, unknown>; + items: Record<string, unknown>[]; + subcontractChecklist: Record<string, unknown> | null; + storageInfo?: Record<string, unknown>[]; +} + +/** + * 일반계약 승인 핸들러 (결재 승인 후 계약승인요청 전송 실행) + * + * 결재 승인 후 자동으로 계약승인요청을 전송함 + * 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨 + * + * @param payload - withApproval()에서 전달한 actionPayload + */ +export async function approveContractInternal(payload: { + contractId: number; + contractSummary: ContractSummary; + currentUser?: { + id: string | number; + name?: string | null; + email?: string | null; + nonsapUserId?: string | null; + }; +}) { + debugLog('[ContractApprovalHandler] 일반계약 승인 핸들러 시작', { + contractId: payload.contractId, + contractNumber: payload.contractSummary.basicInfo?.contractNumber, + contractName: payload.contractSummary.basicInfo?.name, + hasCurrentUser: !!payload.currentUser, + }); + + try { + // 1. 계약 정보 확인 + const [contract] = await db + .select() + .from(generalContracts) + .where(eq(generalContracts.id, payload.contractId)) + .limit(1); + + if (!contract) { + throw new Error('계약을 찾을 수 없습니다.'); + } + + // 2. 계약승인요청 전송 + debugLog('[ContractApprovalHandler] sendContractApprovalRequest 호출'); + + // PDF 경로에서 PDF 버퍼 읽기 + const pdfPath = (payload.contractSummary as any).pdfPath; + if (!pdfPath) { + throw new Error('PDF 경로가 없습니다.'); + } + + // PDF 파일 읽기 + const fs = await import('fs/promises'); + const path = await import('path'); + + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + const baseDir = isProduction ? nasPath : path.join(process.cwd(), "public"); + + // publicPath에서 실제 파일 경로로 변환 + const actualPath = pdfPath.startsWith('/') + ? path.join(baseDir, pdfPath) + : path.join(baseDir, 'generalContracts', pdfPath); + + let pdfBuffer: Uint8Array; + try { + const fileBuffer = await fs.readFile(actualPath); + pdfBuffer = new Uint8Array(fileBuffer); + } catch (error) { + debugError('[ContractApprovalHandler] PDF 파일 읽기 실패', error); + throw new Error('PDF 파일을 읽을 수 없습니다.'); + } + + // 기본계약서는 클라이언트에서 이미 생성되었을 것으로 가정 + const generatedBasicContracts: Array<{ key: string; buffer: number[]; fileName: string }> = + (payload.contractSummary as any).basicContractPdfs || []; + + const userId = payload.currentUser?.id + ? String(payload.currentUser.id) + : String(contract.registeredById); + + const result = await sendContractApprovalRequest( + payload.contractSummary, + pdfBuffer, + 'contractDocument', + userId, + generatedBasicContracts + ); + + if (!result.success) { + debugError('[ContractApprovalHandler] 계약승인요청 전송 실패', result.error); + + // 전송 실패 시 상태를 원래대로 되돌림 + await db.update(generalContracts) + .set({ + status: 'Draft', + lastUpdatedAt: new Date() + }) + .where(eq(generalContracts.id, payload.contractId)); + + throw new Error(result.error || '계약승인요청 전송에 실패했습니다.'); + } + + // 3. 전송 성공 시 상태를 'Contract Accept Request'로 변경 + debugLog('[ContractApprovalHandler] 계약승인요청 전송 성공, 상태를 Contract Accept Request로 변경'); + await db.update(generalContracts) + .set({ + status: 'Contract Accept Request', + lastUpdatedAt: new Date() + }) + .where(eq(generalContracts.id, payload.contractId)); + + debugSuccess('[ContractApprovalHandler] 일반계약 승인 완료', { + contractId: payload.contractId, + result: result + }); + + return { + success: true, + message: '계약승인요청이 전송되었습니다.', + result: result + }; + } catch (error) { + debugError('[ContractApprovalHandler] 일반계약 승인 중 에러', error); + + // 에러 발생 시 상태를 원래대로 되돌림 + try { + await db.update(generalContracts) + .set({ + status: 'Draft', + lastUpdatedAt: new Date() + }) + .where(eq(generalContracts.id, payload.contractId)); + } catch (updateError) { + debugError('[ContractApprovalHandler] 상태 업데이트 실패', updateError); + } + + throw error; + } +} + diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts index 3f3dc8de..b803d2d4 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)
@@ -1391,7 +1386,7 @@ export async function sendContractApprovalRequest( signerStatus: 'PENDING',
})
- // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정
+ // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정 - 수정필요 12/05
if (contractSummary.basicInfo?.externalYardEntry === 'Y') {
try {
// 안전담당자 역할을 가진 사용자 조회 (역할명에 '안전' 또는 'safety' 포함)
diff --git a/lib/information/service.ts b/lib/information/service.ts index 02efe616..39e810e4 100644 --- a/lib/information/service.ts +++ b/lib/information/service.ts @@ -3,7 +3,7 @@ import { getErrorMessage } from "@/lib/handle-error" import { desc, or, eq } from "drizzle-orm" import db from "@/db/db" -import { pageInformation, menuAssignments, users } from "@/db/schema" +import { pageInformation, menuTreeNodes, users } from "@/db/schema" import { saveDRMFile } from "@/lib/file-stroage" import { decryptWithServerAction } from "@/components/drm/drmUtils" @@ -144,27 +144,27 @@ export async function checkInformationEditPermission(pagePath: string, userId: s pagePath // 원본 경로 정확한 매칭 ] - // menu_assignments에서 해당 pagePath와 매칭되는 메뉴 찾기 - const menuAssignment = await db + // menu_tree_nodes에서 해당 pagePath와 매칭되는 메뉴 찾기 + const menuNode = await db .select() - .from(menuAssignments) + .from(menuTreeNodes) .where( or( - ...menuPathQueries.map(path => eq(menuAssignments.menuPath, path)) + ...menuPathQueries.map(path => eq(menuTreeNodes.menuPath, path)) ) ) .limit(1) - if (menuAssignment.length === 0) { + if (menuNode.length === 0) { // 매칭되는 메뉴가 없으면 권한 없음 return false } - const assignment = menuAssignment[0] + const node = menuNode[0] const userIdNumber = parseInt(userId) // 현재 사용자가 manager1 또는 manager2인지 확인 - return assignment.manager1Id === userIdNumber || assignment.manager2Id === userIdNumber + return node.manager1Id === userIdNumber || node.manager2Id === userIdNumber } catch (error) { console.error("Failed to check information edit permission:", error) return false @@ -176,17 +176,21 @@ export async function getEditPermissionDirect(pagePath: string, userId: string) return await checkInformationEditPermission(pagePath, userId) } -// menu_assignments 기반으로 page_information 동기화 +// menu_tree_nodes 기반으로 page_information 동기화 export async function syncInformationFromMenuAssignments() { try { - // menu_assignments에서 모든 메뉴 가져오기 - const menuItems = await db.select().from(menuAssignments); + // menu_tree_nodes에서 메뉴 타입 노드만 가져오기 (menuPath가 있는 것) + const menuItems = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.nodeType, 'menu')); let processedCount = 0; // upsert를 사용하여 각 메뉴 항목 처리 for (const menu of menuItems) { try { + if (!menu.menuPath) continue; + // 맨 앞의 / 제거하여 pagePath 정규화 const normalizedPagePath = menu.menuPath.startsWith('/') ? menu.menuPath.slice(1) @@ -195,14 +199,14 @@ export async function syncInformationFromMenuAssignments() { await db.insert(pageInformation) .values({ pagePath: normalizedPagePath, - pageName: menu.menuTitle, + pageName: menu.titleKo, informationContent: "", isActive: true // 기본값으로 활성화 }) .onConflictDoUpdate({ target: pageInformation.pagePath, set: { - pageName: menu.menuTitle, + pageName: menu.titleKo, updatedAt: new Date() } }); @@ -213,8 +217,6 @@ export async function syncInformationFromMenuAssignments() { } } - // 캐시 무효화 제거됨 - return { success: true, message: `페이지 정보 동기화 완료: ${processedCount}개 처리됨` diff --git a/lib/items-tech/table/add-items-dialog.tsx b/lib/items-tech/table/add-items-dialog.tsx index 01a072da..a4c644b6 100644 --- a/lib/items-tech/table/add-items-dialog.tsx +++ b/lib/items-tech/table/add-items-dialog.tsx @@ -34,7 +34,7 @@ import { } from "@/components/ui/select"
import { toast } from "sonner"
-import { createShipbuildingItem, createOffshoreTopItem, createOffshoreHullItem } from "../service"
+import { createShipbuildingItem, createOffshoreTopItem, createOffshoreHullItem, getShipTypes } from "../service"
import { ItemType } from "./delete-items-dialog"
// 조선 공종 유형 정의
@@ -88,6 +88,8 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { const router = useRouter()
const [open, setOpen] = React.useState(false)
const [isAddPending, startAddTransition] = React.useTransition()
+ const [shipTypeOptions, setShipTypeOptions] = React.useState<string[]>([])
+ const [isShipTypeLoading, setIsShipTypeLoading] = React.useState(false)
// 기본값 설정
const getDefaultValues = () => {
@@ -97,7 +99,7 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { }
if (itemType === 'shipbuilding') {
- defaults.shipTypes = "OPTION"
+ defaults.shipTypes = ""
} else {
defaults.itemList = ""
defaults.subItemList = ""
@@ -124,6 +126,42 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { defaultValues: getDefaultValues(),
})
+ // shipTypes 목록 로드 (조선 아이템 생성 시)
+ React.useEffect(() => {
+ if (itemType !== 'shipbuilding' || !open) return
+
+ let isMounted = true
+ const loadShipTypes = async () => {
+ try {
+ setIsShipTypeLoading(true)
+ const { data, error } = await getShipTypes()
+ if (!isMounted) return
+ if (error) {
+ toast.error("선종 목록을 불러오지 못했습니다")
+ return
+ }
+ const options = (data || []).filter((v): v is string => Boolean(v))
+ setShipTypeOptions(options)
+ // 기본값 자동 설정
+ if (options.length > 0 && !form.getValues("shipTypes")) {
+ form.setValue("shipTypes", options[0])
+ }
+ } catch (err) {
+ console.error("shipTypes load error:", err)
+ if (isMounted) {
+ toast.error("선종 목록 로드 중 오류가 발생했습니다")
+ }
+ } finally {
+ if (isMounted) setIsShipTypeLoading(false)
+ }
+ }
+
+ loadShipTypes()
+ return () => {
+ isMounted = false
+ }
+ }, [itemType, open, form])
+
const onSubmit = async (data: ItemFormValues) => {
startAddTransition(async () => {
try {
@@ -276,7 +314,28 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { <FormItem>
<FormLabel>선종 <span style={{ color: 'red' }}>*</span></FormLabel>
<FormControl>
- <Input placeholder="선종을 입력하세요" {...field} />
+ <Select
+ onValueChange={field.onChange}
+ value={field.value ?? ""}
+ disabled={isShipTypeLoading || shipTypeOptions.length === 0}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder={isShipTypeLoading ? "불러오는 중..." : "선종을 선택하세요"} />
+ </SelectTrigger>
+ <SelectContent>
+ {shipTypeOptions.length === 0 ? (
+ <div className="px-3 py-2 text-sm text-muted-foreground">
+ {isShipTypeLoading ? "불러오는 중..." : "선종 없음"}
+ </div>
+ ) : (
+ shipTypeOptions.map((type) => (
+ <SelectItem key={type} value={type}>
+ {type}
+ </SelectItem>
+ ))
+ )}
+ </SelectContent>
+ </Select>
</FormControl>
<FormMessage />
</FormItem>
diff --git a/lib/menu-v2/components/add-node-dialog.tsx b/lib/menu-v2/components/add-node-dialog.tsx new file mode 100644 index 00000000..b6762820 --- /dev/null +++ b/lib/menu-v2/components/add-node-dialog.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { + MenuDomain, + CreateMenuGroupInput, + CreateGroupInput, + CreateTopLevelMenuInput +} from "../types"; + +type DialogType = "menu_group" | "group" | "top_level_menu"; + +interface AddNodeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + type: DialogType; + domain: MenuDomain; + parentId?: number; // group 생성 시 필요 + onSave: (data: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput) => Promise<void>; +} + +interface FormData { + titleKo: string; + titleEn: string; + menuPath: string; +} + +export function AddNodeDialog({ + open, + onOpenChange, + type, + domain, + parentId, + onSave, +}: AddNodeDialogProps) { + const { + register, + handleSubmit, + reset, + formState: { isSubmitting, errors }, + } = useForm<FormData>({ + defaultValues: { + titleKo: "", + titleEn: "", + menuPath: "", + }, + }); + + const getTitle = () => { + switch (type) { + case "menu_group": + return "Add Menu Group"; + case "group": + return "Add Group"; + case "top_level_menu": + return "Add Top-Level Menu"; + default: + return "Add"; + } + }; + + const getDescription = () => { + switch (type) { + case "menu_group": + return "A dropdown trigger displayed in the header navigation."; + case "group": + return "Groups menus within a menu group."; + case "top_level_menu": + return "A single link displayed in the header navigation."; + default: + return ""; + } + }; + + const onSubmit = async (data: FormData) => { + let saveData: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput; + + if (type === "menu_group") { + saveData = { + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + }; + } else if (type === "group" && parentId) { + saveData = { + parentId, + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + }; + } else if (type === "top_level_menu") { + saveData = { + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + menuPath: data.menuPath, + }; + } else { + return; + } + + await onSave(saveData); + reset(); + onOpenChange(false); + }; + + const handleClose = () => { + reset(); + onOpenChange(false); + }; + + return ( + <Dialog open={open} onOpenChange={handleClose}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>{getTitle()}</DialogTitle> + <DialogDescription>{getDescription()}</DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> + <div className="grid gap-4"> + {/* Korean Name */} + <div className="grid gap-2"> + <Label htmlFor="titleKo">Name (Korean) *</Label> + <Input + id="titleKo" + {...register("titleKo", { required: "Name is required" })} + placeholder="Master Data" + /> + {errors.titleKo && ( + <p className="text-xs text-destructive">{errors.titleKo.message}</p> + )} + </div> + + {/* English Name */} + <div className="grid gap-2"> + <Label htmlFor="titleEn">Name (English)</Label> + <Input + id="titleEn" + {...register("titleEn")} + placeholder="Master Data" + /> + </div> + + {/* Menu Path for Top-Level Menu */} + {type === "top_level_menu" && ( + <div className="grid gap-2"> + <Label htmlFor="menuPath">Menu Path *</Label> + <Input + id="menuPath" + {...register("menuPath", { + required: type === "top_level_menu" ? "Path is required" : false + })} + placeholder={`/${domain}/dashboard`} + /> + {errors.menuPath && ( + <p className="text-xs text-destructive">{errors.menuPath.message}</p> + )} + <p className="text-xs text-muted-foreground"> + e.g., /{domain}/report, /{domain}/faq + </p> + </div> + )} + </div> + + <DialogFooter> + <Button type="button" variant="outline" onClick={handleClose}> + Cancel + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "Creating..." : "Create"} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ); +} diff --git a/lib/menu-v2/components/domain-tabs.tsx b/lib/menu-v2/components/domain-tabs.tsx new file mode 100644 index 00000000..e52fa80b --- /dev/null +++ b/lib/menu-v2/components/domain-tabs.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { MenuDomain } from "../types"; + +interface DomainTabsProps { + value: MenuDomain; + onChange: (domain: MenuDomain) => void; +} + +export function DomainTabs({ value, onChange }: DomainTabsProps) { + return ( + <Tabs value={value} onValueChange={(v) => onChange(v as MenuDomain)}> + <TabsList> + <TabsTrigger value="evcp"> + EVCP (Internal) + </TabsTrigger> + <TabsTrigger value="partners"> + Partners (Vendors) + </TabsTrigger> + </TabsList> + </Tabs> + ); +} + diff --git a/lib/menu-v2/components/edit-node-dialog.tsx b/lib/menu-v2/components/edit-node-dialog.tsx new file mode 100644 index 00000000..9631a611 --- /dev/null +++ b/lib/menu-v2/components/edit-node-dialog.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import type { MenuTreeNode, UpdateNodeInput } from "../types"; + +interface EditNodeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + node: MenuTreeNode | null; + onSave: (nodeId: number, data: UpdateNodeInput) => Promise<void>; +} + +interface FormData { + titleKo: string; + titleEn: string; + descriptionKo: string; + descriptionEn: string; + scrId: string; + isActive: boolean; +} + +export function EditNodeDialog({ + open, + onOpenChange, + node, + onSave, +}: EditNodeDialogProps) { + const { + register, + handleSubmit, + reset, + setValue, + watch, + formState: { isSubmitting }, + } = useForm<FormData>({ + defaultValues: { + titleKo: "", + titleEn: "", + descriptionKo: "", + descriptionEn: "", + scrId: "", + isActive: true, + }, + }); + + const isActive = watch("isActive"); + + useEffect(() => { + if (node) { + reset({ + titleKo: node.titleKo, + titleEn: node.titleEn || "", + descriptionKo: node.descriptionKo || "", + descriptionEn: node.descriptionEn || "", + scrId: node.scrId || "", + isActive: node.isActive, + }); + } + }, [node, reset]); + + const onSubmit = async (data: FormData) => { + if (!node) return; + + await onSave(node.id, { + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + descriptionKo: data.descriptionKo || undefined, + descriptionEn: data.descriptionEn || undefined, + scrId: data.scrId || undefined, + isActive: data.isActive, + }); + + onOpenChange(false); + }; + + const getTypeLabel = () => { + switch (node?.nodeType) { + case "menu_group": + return "Menu Group"; + case "group": + return "Group"; + case "menu": + return "Menu"; + case "additional": + return "Additional Menu"; + default: + return "Node"; + } + }; + + const showMenuFields = node?.nodeType === "menu" || node?.nodeType === "additional"; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle>Edit {getTypeLabel()}</DialogTitle> + <DialogDescription> + {node?.menuPath && ( + <span className="text-xs text-muted-foreground">{node.menuPath}</span> + )} + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> + <div className="grid gap-4"> + {/* Korean Name */} + <div className="grid gap-2"> + <Label htmlFor="titleKo">Name (Korean) *</Label> + <Input + id="titleKo" + {...register("titleKo", { required: true })} + placeholder="Project List" + /> + </div> + + {/* English Name */} + <div className="grid gap-2"> + <Label htmlFor="titleEn">Name (English)</Label> + <Input + id="titleEn" + {...register("titleEn")} + placeholder="Project List" + /> + </div> + + {/* Korean Description */} + {showMenuFields && ( + <div className="grid gap-2"> + <Label htmlFor="descriptionKo">Description (Korean)</Label> + <Textarea + id="descriptionKo" + {...register("descriptionKo")} + placeholder="Project list from MDG (C)" + rows={2} + /> + </div> + )} + + {/* English Description */} + {showMenuFields && ( + <div className="grid gap-2"> + <Label htmlFor="descriptionEn">Description (English)</Label> + <Textarea + id="descriptionEn" + {...register("descriptionEn")} + placeholder="Project list from MDG (C)" + rows={2} + /> + </div> + )} + + {/* Permission SCR_ID */} + {showMenuFields && ( + <div className="grid gap-2"> + <Label htmlFor="scrId">Permission SCR_ID (EVCP only)</Label> + <Input + id="scrId" + {...register("scrId")} + placeholder="SCR_001" + /> + <p className="text-xs text-muted-foreground"> + Linked with Oracle DB SCR_ID. If empty, auto-matched by URL. + </p> + </div> + )} + + {/* Active Status */} + <div className="flex items-center justify-between"> + <div className="space-y-0.5"> + <Label htmlFor="isActive">Show in Menu</Label> + <p className="text-xs text-muted-foreground"> + When disabled, hidden from the navigation menu. + </p> + </div> + <Switch + id="isActive" + checked={isActive} + onCheckedChange={(checked) => setValue("isActive", checked)} + /> + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + Cancel + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "Saving..." : "Save"} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/menu-v2/components/menu-tree-manager.tsx b/lib/menu-v2/components/menu-tree-manager.tsx new file mode 100644 index 00000000..337eaee4 --- /dev/null +++ b/lib/menu-v2/components/menu-tree-manager.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { useState, useEffect, useCallback, useTransition } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { RefreshCw, Plus, Loader2 } from "lucide-react"; +import { DomainTabs } from "./domain-tabs"; +import { MenuTree } from "./menu-tree"; +import { EditNodeDialog } from "./edit-node-dialog"; +import { AddNodeDialog } from "./add-node-dialog"; +import { MoveToDialog } from "./move-to-dialog"; +import { UnassignedMenusPanel } from "./unassigned-menus-panel"; +import { + getMenuTreeForAdmin, + createMenuGroup, + createGroup, + createTopLevelMenu, + updateNode, + moveNodeUp, + moveNodeDown, + moveNodeToParent, + getAvailableParents, + assignMenuToGroup, + activateAsTopLevelMenu, + syncDiscoveredMenus, +} from "../service"; +import type { + MenuDomain, + MenuTreeNode, + MenuTreeAdminResult, + UpdateNodeInput, + CreateMenuGroupInput, + CreateGroupInput, + CreateTopLevelMenuInput, +} from "../types"; + +interface MenuTreeManagerProps { + initialDomain?: MenuDomain; +} + +export function MenuTreeManager({ initialDomain = "evcp" }: MenuTreeManagerProps) { + const [domain, setDomain] = useState<MenuDomain>(initialDomain); + const [data, setData] = useState<MenuTreeAdminResult | null>(null); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [isPending, startTransition] = useTransition(); + + // Dialog states + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingNode, setEditingNode] = useState<MenuTreeNode | null>(null); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [addDialogType, setAddDialogType] = useState<"menu_group" | "group" | "top_level_menu">("menu_group"); + const [addGroupParentId, setAddGroupParentId] = useState<number | undefined>(undefined); + + // Move dialog state + const [moveDialogOpen, setMoveDialogOpen] = useState(false); + const [movingNode, setMovingNode] = useState<MenuTreeNode | null>(null); + const [availableParents, setAvailableParents] = useState<{ id: number | null; title: string; depth: number }[]>([]); + + // Tree expansion state + const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set()); + + // Load data using server action + const loadData = useCallback(async (isRefresh = false) => { + if (!isRefresh) { + setIsInitialLoading(true); + } + try { + const result = await getMenuTreeForAdmin(domain); + setData(result); + } catch (error) { + console.error("Error loading menu tree:", error); + toast.error("Failed to load menu tree"); + } finally { + setIsInitialLoading(false); + } + }, [domain]); + + useEffect(() => { + setExpandedIds(new Set()); + loadData(); + }, [loadData]); + + const handleSync = async () => { + startTransition(async () => { + try { + const result = await syncDiscoveredMenus(domain); + toast.success(`Sync complete: ${result.added} menus added`); + loadData(true); + } catch (error) { + console.error("Error syncing menus:", error); + toast.error("Failed to sync menus"); + } + }); + }; + + const handleEdit = (node: MenuTreeNode) => { + setEditingNode(node); + setEditDialogOpen(true); + }; + + const handleSaveEdit = async (nodeId: number, input: UpdateNodeInput) => { + startTransition(async () => { + try { + await updateNode(nodeId, input); + toast.success("Saved successfully"); + loadData(true); + } catch (error) { + console.error("Error updating node:", error); + toast.error("Failed to save"); + } + }); + }; + + // Move up (within same parent) + const handleMoveUp = async (nodeId: number) => { + startTransition(async () => { + try { + await moveNodeUp(nodeId); + loadData(true); + } catch (error) { + console.error("Error moving node up:", error); + toast.error("Failed to move"); + } + }); + }; + + // Move down (within same parent) + const handleMoveDown = async (nodeId: number) => { + startTransition(async () => { + try { + await moveNodeDown(nodeId); + loadData(true); + } catch (error) { + console.error("Error moving node down:", error); + toast.error("Failed to move"); + } + }); + }; + + // Open move to dialog + const handleOpenMoveDialog = async (node: MenuTreeNode) => { + setMovingNode(node); + try { + const parents = await getAvailableParents(node.id, domain, node.nodeType); + setAvailableParents(parents); + setMoveDialogOpen(true); + } catch (error) { + console.error("Error loading available parents:", error); + toast.error("Failed to load move options"); + } + }; + + // Execute move to different parent + const handleMoveTo = async (newParentId: number | null) => { + if (!movingNode) return; + startTransition(async () => { + try { + await moveNodeToParent(movingNode.id, newParentId); + toast.success("Moved successfully"); + setMoveDialogOpen(false); + setMovingNode(null); + loadData(true); + } catch (error) { + console.error("Error moving node:", error); + toast.error("Failed to move"); + } + }); + }; + + const handleAddMenuGroup = () => { + setAddDialogType("menu_group"); + setAddGroupParentId(undefined); + setAddDialogOpen(true); + }; + + const handleAddGroup = (parentId: number) => { + setAddDialogType("group"); + setAddGroupParentId(parentId); + setAddDialogOpen(true); + }; + + const handleAddTopLevelMenu = () => { + setAddDialogType("top_level_menu"); + setAddGroupParentId(undefined); + setAddDialogOpen(true); + }; + + const handleSaveAdd = async ( + input: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput + ) => { + startTransition(async () => { + try { + if (addDialogType === "menu_group") { + await createMenuGroup(domain, input as CreateMenuGroupInput); + } else if (addDialogType === "group") { + await createGroup(domain, input as CreateGroupInput); + } else if (addDialogType === "top_level_menu") { + await createTopLevelMenu(domain, input as CreateTopLevelMenuInput); + } + toast.success("Created successfully"); + loadData(true); + } catch (error) { + console.error("Error creating node:", error); + toast.error("Failed to create"); + } + }); + }; + + const handleAssign = async (menuId: number, groupId: number) => { + startTransition(async () => { + try { + await assignMenuToGroup(menuId, groupId); + toast.success("Assigned successfully"); + loadData(true); + } catch (error) { + console.error("Error assigning menu:", error); + toast.error("Failed to assign"); + } + }); + }; + + const handleActivateAsTopLevel = async (menuId: number) => { + startTransition(async () => { + try { + await activateAsTopLevelMenu(menuId); + toast.success("Activated as top-level menu"); + loadData(true); + } catch (error) { + console.error("Error activating as top level:", error); + toast.error("Failed to activate"); + } + }); + }; + + // Build list of available groups for assignment + const getAvailableGroups = () => { + if (!data) return []; + + const groups: { id: number; title: string; parentTitle?: string }[] = []; + + for (const node of data.tree) { + if (node.nodeType !== 'menu_group') continue; + + groups.push({ id: node.id, title: node.titleKo }); + + if (node.children) { + for (const child of node.children) { + if (child.nodeType === "group") { + groups.push({ + id: child.id, + title: child.titleKo, + parentTitle: node.titleKo, + }); + } + } + } + } + + return groups; + }; + + if (isInitialLoading) { + return ( + <div className="flex items-center justify-center h-96"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + </div> + ); + } + + return ( + <div className="space-y-6"> + {/* Header */} + <div className="flex items-center justify-between"> + <DomainTabs value={domain} onChange={setDomain} /> + <div className="flex items-center gap-2"> + {/* [jh] I've commented this button.. */} + {/* <Button variant="outline" size="sm" onClick={handleSync} disabled={isPending}> + <RefreshCw className={`mr-2 h-4 w-4 ${isPending ? "animate-spin" : ""}`} /> + Sync Pages + </Button> */} + <Button variant="outline" size="sm" onClick={handleAddTopLevelMenu} disabled={isPending}> + <Plus className="mr-2 h-4 w-4" /> + Add Top-Level Menu + </Button> + <Button size="sm" onClick={handleAddMenuGroup} disabled={isPending}> + <Plus className="mr-2 h-4 w-4" /> + Add Menu Group + </Button> + </div> + </div> + + {/* Main Content */} + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> + {/* Menu Tree */} + <div className="lg:col-span-2"> + <Card> + <CardHeader> + <CardTitle>{domain === "evcp" ? "EVCP" : "Partners"} Menu Structure</CardTitle> + <CardDescription> + Use arrow buttons to reorder, or click Move To to change parent. + </CardDescription> + </CardHeader> + <CardContent> + {data?.tree && data.tree.length > 0 ? ( + <MenuTree + nodes={data.tree} + onEdit={handleEdit} + onMoveUp={handleMoveUp} + onMoveDown={handleMoveDown} + onMoveTo={handleOpenMoveDialog} + onAddGroup={handleAddGroup} + expandedIds={expandedIds} + onExpandedIdsChange={setExpandedIds} + isPending={isPending} + /> + ) : ( + <p className="text-sm text-muted-foreground text-center py-8"> + No menus. Add one using the buttons above. + </p> + )} + </CardContent> + </Card> + </div> + + {/* Unassigned Menus */} + <div className="lg:col-span-1"> + <UnassignedMenusPanel + menus={data?.unassigned || []} + onAssign={handleAssign} + onActivateAsTopLevel={handleActivateAsTopLevel} + onEdit={handleEdit} + availableGroups={getAvailableGroups()} + /> + </div> + </div> + + {/* Dialogs */} + <EditNodeDialog + open={editDialogOpen} + onOpenChange={setEditDialogOpen} + node={editingNode} + onSave={handleSaveEdit} + /> + + <AddNodeDialog + open={addDialogOpen} + onOpenChange={setAddDialogOpen} + type={addDialogType} + domain={domain} + parentId={addGroupParentId} + onSave={handleSaveAdd} + /> + + <MoveToDialog + open={moveDialogOpen} + onOpenChange={setMoveDialogOpen} + node={movingNode} + availableParents={availableParents} + onMove={handleMoveTo} + /> + </div> + ); +} diff --git a/lib/menu-v2/components/menu-tree.tsx b/lib/menu-v2/components/menu-tree.tsx new file mode 100644 index 00000000..7d3ab077 --- /dev/null +++ b/lib/menu-v2/components/menu-tree.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + ChevronRight, + ChevronDown, + ChevronUp, + Folder, + FolderOpen, + File, + Pencil, + Plus, + ArrowUpDown, + EyeOff, +} from "lucide-react"; +import type { MenuTreeNode } from "../types"; + +interface MenuTreeProps { + nodes: MenuTreeNode[]; + onEdit: (node: MenuTreeNode) => void; + onMoveUp: (nodeId: number) => void; + onMoveDown: (nodeId: number) => void; + onMoveTo: (node: MenuTreeNode) => void; + onAddGroup: (parentId: number) => void; + expandedIds: Set<number>; + onExpandedIdsChange: (ids: Set<number>) => void; + isPending?: boolean; +} + +interface TreeItemProps { + node: MenuTreeNode; + depth: number; + isFirst: boolean; + isLast: boolean; + onEdit: (node: MenuTreeNode) => void; + onMoveUp: (nodeId: number) => void; + onMoveDown: (nodeId: number) => void; + onMoveTo: (node: MenuTreeNode) => void; + onAddGroup: (parentId: number) => void; + isExpanded: boolean; + onToggleExpand: () => void; + isPending?: boolean; +} + +function TreeItem({ + node, + depth, + isFirst, + isLast, + onEdit, + onMoveUp, + onMoveDown, + onMoveTo, + onAddGroup, + isExpanded, + onToggleExpand, + isPending, +}: TreeItemProps) { + const isMenuGroup = node.nodeType === "menu_group"; + const isGroup = node.nodeType === "group"; + const isMenu = node.nodeType === "menu"; + const isTopLevel = node.parentId === null; + const hasChildren = node.children && node.children.length > 0; + const isExpandable = isMenuGroup || isGroup; + + // Move To is disabled for: + // - menu_group (always at top level, cannot be moved) + // - top-level menu (parentId === null, can only reorder with up/down) + const canMoveTo = !isMenuGroup && !isTopLevel; + + const getIcon = () => { + if (isMenuGroup || isGroup) { + return isExpanded ? ( + <FolderOpen className="h-4 w-4 text-amber-500" /> + ) : ( + <Folder className="h-4 w-4 text-amber-500" /> + ); + } + return <File className="h-4 w-4 text-slate-500" />; + }; + + const getTypeLabel = () => { + switch (node.nodeType) { + case "menu_group": return "Menu Group"; + case "group": return "Group"; + case "menu": return "Menu"; + default: return ""; + } + }; + + return ( + <div + className={cn( + "flex items-center gap-2 px-2 py-1.5 rounded-md border bg-background hover:bg-accent/50 transition-colors", + !node.isActive && "opacity-50 bg-muted/30 border-dashed" + )} + style={{ marginLeft: depth * 24 }} + > + {/* Expand/Collapse */} + {isExpandable ? ( + <button + onClick={onToggleExpand} + className="p-0.5 hover:bg-accent rounded shrink-0" + > + {isExpanded ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + </button> + ) : ( + <div className="w-5 shrink-0" /> + )} + + {/* Icon */} + {getIcon()} + + {/* Title */} + <span className={cn( + "flex-1 text-sm font-medium truncate min-w-0", + !node.isActive && "line-through text-muted-foreground" + )}> + {node.titleKo} + {node.titleEn && ( + <span className="text-muted-foreground font-normal"> [{node.titleEn}]</span> + )} + </span> + + {/* Hidden indicator */} + {!node.isActive && ( + <EyeOff className="h-3.5 w-3.5 text-muted-foreground shrink-0" title="Hidden" /> + )} + + {/* Path (for menus) */} + {isMenu && node.menuPath && ( + <span className="text-xs text-muted-foreground truncate max-w-[150px] shrink-0"> + {node.menuPath} + </span> + )} + + {/* Type Badge */} + <Badge variant={node.isActive ? "default" : "secondary"} className="text-xs shrink-0"> + {getTypeLabel()} + </Badge> + + {/* Active indicator */} + <div + className={cn( + "w-2 h-2 rounded-full shrink-0", + node.isActive ? "bg-green-500" : "bg-gray-400" + )} + title={node.isActive ? "Visible" : "Hidden"} + /> + + {/* Actions */} + <div className="flex items-center gap-1 shrink-0"> + {/* Move Up */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onMoveUp(node.id)} + disabled={isFirst || isPending} + title="Move Up" + > + <ChevronUp className="h-4 w-4" /> + </Button> + + {/* Move Down */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onMoveDown(node.id)} + disabled={isLast || isPending} + title="Move Down" + > + <ChevronDown className="h-4 w-4" /> + </Button> + + {/* Move To (different parent) - disabled for top level nodes */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onMoveTo(node)} + disabled={!canMoveTo || isPending} + title={canMoveTo ? "Move To..." : "Cannot move top-level items"} + > + <ArrowUpDown className="h-4 w-4" /> + </Button> + + {/* Edit */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onEdit(node)} + disabled={isPending} + title="Edit" + > + <Pencil className="h-4 w-4" /> + </Button> + + {/* Add Sub-Group (for menu groups only) */} + {isMenuGroup && ( + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onAddGroup(node.id)} + disabled={isPending} + title="Add Sub-Group" + > + <Plus className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ); +} + +export function MenuTree({ + nodes, + onEdit, + onMoveUp, + onMoveDown, + onMoveTo, + onAddGroup, + expandedIds, + onExpandedIdsChange, + isPending, +}: MenuTreeProps) { + const toggleExpand = useCallback((nodeId: number) => { + const next = new Set(expandedIds); + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + onExpandedIdsChange(next); + }, [expandedIds, onExpandedIdsChange]); + + const renderTree = (nodeList: MenuTreeNode[], depth: number) => { + return nodeList.map((node, index) => { + const isExpanded = expandedIds.has(node.id); + const isExpandable = node.nodeType === "menu_group" || node.nodeType === "group"; + const hasChildren = node.children && node.children.length > 0; + + return ( + <div key={node.id} className="space-y-1"> + <TreeItem + node={node} + depth={depth} + isFirst={index === 0} + isLast={index === nodeList.length - 1} + onEdit={onEdit} + onMoveUp={onMoveUp} + onMoveDown={onMoveDown} + onMoveTo={onMoveTo} + onAddGroup={onAddGroup} + isExpanded={isExpanded} + onToggleExpand={() => toggleExpand(node.id)} + isPending={isPending} + /> + {isExpandable && isExpanded && hasChildren && ( + <div className="space-y-1"> + {renderTree(node.children!, depth + 1)} + </div> + )} + </div> + ); + }); + }; + + return <div className="space-y-1">{renderTree(nodes, 0)}</div>; +} + + diff --git a/lib/menu-v2/components/move-to-dialog.tsx b/lib/menu-v2/components/move-to-dialog.tsx new file mode 100644 index 00000000..7253708b --- /dev/null +++ b/lib/menu-v2/components/move-to-dialog.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { Folder, FolderOpen, Home } from "lucide-react"; +import type { MenuTreeNode } from "../types"; + +interface MoveToDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + node: MenuTreeNode | null; + availableParents: { id: number | null; title: string; depth: number }[]; + onMove: (newParentId: number | null) => void; +} + +export function MoveToDialog({ + open, + onOpenChange, + node, + availableParents, + onMove, +}: MoveToDialogProps) { + if (!node) return null; + + const isCurrent = (parentId: number | null) => node.parentId === parentId; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Move To</DialogTitle> + <DialogDescription> + Select a new location for "{node.titleKo}" + </DialogDescription> + </DialogHeader> + + <ScrollArea className="max-h-[400px]"> + <div className="space-y-0.5 p-1"> + {availableParents.map((parent) => ( + <Button + key={parent.id ?? 'root'} + variant={isCurrent(parent.id) ? "secondary" : "ghost"} + className={cn( + "w-full justify-start h-auto py-2 text-sm", + parent.depth === 0 && "font-medium", + parent.depth === 1 && "font-medium", + parent.depth === 2 && "text-muted-foreground" + )} + style={{ paddingLeft: parent.depth * 20 + 8 }} + onClick={() => onMove(parent.id)} + disabled={isCurrent(parent.id)} + > + {parent.id === null ? ( + <Home className="mr-2 h-4 w-4 text-blue-500 shrink-0" /> + ) : parent.depth === 1 ? ( + <FolderOpen className="mr-2 h-4 w-4 text-amber-500 shrink-0" /> + ) : ( + <Folder className="mr-2 h-4 w-4 text-amber-400 shrink-0" /> + )} + <span className="truncate">{parent.title}</span> + {isCurrent(parent.id) && ( + <span className="ml-auto text-xs text-muted-foreground shrink-0">(current)</span> + )} + </Button> + ))} + </div> + </ScrollArea> + + <div className="flex justify-end"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + </div> + </DialogContent> + </Dialog> + ); +} + + diff --git a/lib/menu-v2/components/unassigned-menus-panel.tsx b/lib/menu-v2/components/unassigned-menus-panel.tsx new file mode 100644 index 00000000..2c914f2a --- /dev/null +++ b/lib/menu-v2/components/unassigned-menus-panel.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Search, FileQuestion, ArrowRight, Pencil, Link } from "lucide-react"; +import type { MenuTreeNode } from "../types"; + +interface UnassignedMenusPanelProps { + menus: MenuTreeNode[]; + onAssign: (menuId: number, groupId: number) => void; + onActivateAsTopLevel: (menuId: number) => void; + onEdit: (menu: MenuTreeNode) => void; + availableGroups: { id: number; title: string; parentTitle?: string }[]; +} + +export function UnassignedMenusPanel({ + menus, + onAssign, + onActivateAsTopLevel, + onEdit, + availableGroups, +}: UnassignedMenusPanelProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedMenu, setSelectedMenu] = useState<number | null>(null); + + const filteredMenus = menus.filter( + (menu) => + menu.titleKo.toLowerCase().includes(searchTerm.toLowerCase()) || + menu.menuPath?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( + <Card className="h-full"> + <CardHeader className="pb-3"> + <CardTitle className="text-base flex items-center gap-2"> + <FileQuestion className="h-4 w-4" /> + Unassigned Menus ({menus.length}) + </CardTitle> + <CardDescription> + Assign to a group or activate as a top-level link. + </CardDescription> + </CardHeader> + <CardContent className="space-y-3"> + {/* Search */} + <div className="relative"> + <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="Search..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="pl-8" + /> + </div> + + {/* Menu List */} + <ScrollArea className="h-[400px]"> + <div className="space-y-2"> + {filteredMenus.length === 0 ? ( + <p className="text-sm text-muted-foreground text-center py-4"> + {searchTerm ? "No results found." : "No unassigned menus."} + </p> + ) : ( + filteredMenus.map((menu) => ( + <div + key={menu.id} + className={cn( + "p-3 rounded-md border bg-background hover:bg-accent/50 transition-colors", + selectedMenu === menu.id && "ring-2 ring-primary" + )} + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="font-medium text-sm">{menu.titleKo}</span> + <Badge variant="secondary" className="text-xs"> + Inactive + </Badge> + </div> + <p className="text-xs text-muted-foreground truncate mt-1"> + {menu.menuPath} + </p> + </div> + <Button + variant="ghost" + size="icon" + className="h-7 w-7 shrink-0" + onClick={() => onEdit(menu)} + > + <Pencil className="h-3.5 w-3.5" /> + </Button> + </div> + + {/* Group Selection (expanded) */} + {selectedMenu === menu.id ? ( + <div className="mt-3 pt-3 border-t space-y-2"> + {/* Activate as Top-Level */} + <div> + <p className="text-xs text-muted-foreground mb-2"> + Activate as top-level link: + </p> + <Button + variant="default" + size="sm" + className="text-xs h-7" + onClick={() => { + onActivateAsTopLevel(menu.id); + setSelectedMenu(null); + }} + > + <Link className="mr-1 h-3 w-3" /> + Activate as Top-Level + </Button> + </div> + + {/* Assign to Group */} + {availableGroups.length > 0 && ( + <div> + <p className="text-xs text-muted-foreground mb-2"> + Or assign to group: + </p> + <div className="flex flex-wrap gap-1"> + {availableGroups.map((group) => ( + <Button + key={group.id} + variant="outline" + size="sm" + className="text-xs h-7" + onClick={() => { + onAssign(menu.id, group.id); + setSelectedMenu(null); + }} + > + {group.parentTitle && ( + <span className="text-muted-foreground mr-1"> + {group.parentTitle} > + </span> + )} + {group.title} + <ArrowRight className="ml-1 h-3 w-3" /> + </Button> + ))} + </div> + </div> + )} + + <Button + variant="ghost" + size="sm" + className="text-xs" + onClick={() => setSelectedMenu(null)} + > + Cancel + </Button> + </div> + ) : ( + <Button + variant="ghost" + size="sm" + className="mt-2 text-xs w-full" + onClick={() => setSelectedMenu(menu.id)} + > + Assign / Activate + </Button> + )} + </div> + )) + )} + </div> + </ScrollArea> + </CardContent> + </Card> + ); +} diff --git a/lib/menu-v2/permission-service.ts b/lib/menu-v2/permission-service.ts new file mode 100644 index 00000000..e495ba23 --- /dev/null +++ b/lib/menu-v2/permission-service.ts @@ -0,0 +1,186 @@ +'use server'; + +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getAllScreens, getAuthsByScreenId, getUserRoles, type ScreenEvcp, type RoleRelEvcp } from "@/lib/nonsap/db"; +import { getActiveMenuTree } from "./service"; +import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "./types"; +import db from "@/db/db"; +import { users } from "@/db/schema/users"; +import { eq } from "drizzle-orm"; + +/** + * Oracle 권한 체크 스킵 여부 확인 + * SKIP_ORACLE_PERMISSION_CHECK=true인 경우 Oracle DB 권한 체크를 건너뜀 + */ +function shouldSkipOraclePermissionCheck(): boolean { + return process.env.SKIP_ORACLE_PERMISSION_CHECK === 'true'; +} + +/** + * 사용자 ID로 employeeNumber 조회 + */ +async function getEmployeeNumberByUserId(userId: number): Promise<string | null> { + const [user] = await db.select({ employeeNumber: users.employeeNumber }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user?.employeeNumber || null; +} + +/** + * Get menu tree filtered by user permissions + * + * @param domain - Domain (evcp | partners) + * @param userId - Optional user ID. If not provided, gets from session. + * + * Environment variable SKIP_ORACLE_PERMISSION_CHECK=true skips Oracle permission check + */ +export async function getVisibleMenuTree( + domain: MenuDomain, + userId?: number +): Promise<MenuTreeActiveResult> { + const { tree: menuTree } = await getActiveMenuTree(domain); + + // Partners domain uses its own permission system (not implemented) + if (domain === 'partners') { + return { tree: menuTree }; + } + + // Skip Oracle permission check in development + if (shouldSkipOraclePermissionCheck()) { + return { tree: menuTree }; + } + + // Get userId from session if not provided + let effectiveUserId = userId; + if (!effectiveUserId) { + const session = await getServerSession(authOptions); + effectiveUserId = session?.user?.id ? parseInt(session.user.id, 10) : undefined; + } + + if (!effectiveUserId) { + return { tree: menuTree }; + } + + // Get employeeNumber from userId + const empNo = await getEmployeeNumberByUserId(effectiveUserId); + if (!empNo) { + return { tree: menuTree }; + } + + let screens: ScreenEvcp[]; + let userRoles: RoleRelEvcp[]; + + try { + [screens, userRoles] = await Promise.all([ + getAllScreens(), + getUserRoles(empNo) + ]); + } catch (error) { + // Oracle DB 연결 실패 시 전체 메뉴 반환 (에러로 인한 접근 차단 방지) + console.error('[menu-v2] Oracle permission check failed, returning all menus:', error); + return { tree: menuTree }; + } + + const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID)); + const screenMap = new Map<string, ScreenEvcp>(screens.map(s => [s.SCR_URL, s])); + + // 메뉴 필터링 (최상위 menu, menu_group, group 모두 처리) + async function filterByPermission(nodes: MenuTreeNode[]): Promise<MenuTreeNode[]> { + const result: MenuTreeNode[] = []; + + for (const node of nodes) { + // 메뉴 노드 (최상위 단일 링크 또는 하위 메뉴) + if (node.nodeType === 'menu' && node.menuPath) { + const screen = screenMap.get(node.menuPath); + + // 화면 정보가 없거나 SCRT_CHK_YN === 'N' 이면 표시 + if (!screen || screen.SCRT_CHK_YN === 'N') { + result.push(node); + continue; + } + + // SCRT_CHK_YN === 'Y' 이면 권한 체크 + if (screen.SCRT_CHK_YN === 'Y') { + const scrIdToCheck = node.scrId || screen.SCR_ID; + const auths = await getAuthsByScreenId(scrIdToCheck); + + const hasAccess = auths.some(auth => { + if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true; + if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true; + return false; + }); + + if (hasAccess) result.push(node); + } + } + // 메뉴그룹 또는 그룹 (자식 필터링 후 자식이 있으면 포함) + else if (node.nodeType === 'menu_group' || node.nodeType === 'group') { + const filteredChildren = await filterByPermission(node.children || []); + if (filteredChildren.length > 0) { + result.push({ ...node, children: filteredChildren }); + } + } + } + + return result; + } + + const filteredTree = await filterByPermission(menuTree); + + return { tree: filteredTree }; +} + +/** + * 특정 메뉴 경로에 대한 접근 권한 확인 + * + * 환경변수 SKIP_ORACLE_PERMISSION_CHECK=true인 경우 항상 true 반환 + */ +export async function checkMenuAccess( + menuPath: string, + userId: number +): Promise<boolean> { + // Oracle 권한 체크 스킵 설정된 경우 + if (shouldSkipOraclePermissionCheck()) { + return true; + } + + const empNo = await getEmployeeNumberByUserId(userId); + if (!empNo) return false; + + try { + const screens = await getAllScreens(); + const screen = screens.find(s => s.SCR_URL === menuPath); + + // 등록되지 않은 화면 또는 권한 체크가 필요 없는 화면 + if (!screen || screen.SCRT_CHK_YN === 'N') { + return true; + } + + // 삭제된 화면 + if (screen.DEL_YN === 'Y') { + return false; + } + + // 권한 체크 + const [auths, userRoles] = await Promise.all([ + getAuthsByScreenId(screen.SCR_ID), + getUserRoles(empNo) + ]); + + const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID)); + + return auths.some(auth => { + if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true; + if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true; + return false; + }); + } catch (error) { + // Oracle DB 연결 실패 시 접근 허용 (에러로 인한 차단 방지) + console.error('[menu-v2] Oracle permission check failed for path:', menuPath, error); + return true; + } +} + diff --git a/lib/menu-v2/service.ts b/lib/menu-v2/service.ts new file mode 100644 index 00000000..39ca144a --- /dev/null +++ b/lib/menu-v2/service.ts @@ -0,0 +1,605 @@ +'use server'; + +import fs from 'fs'; +import path from 'path'; +import db from "@/db/db"; +import { menuTreeNodes } from "@/db/schema/menu-v2"; +import { eq, and, asc, inArray, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import type { + MenuDomain, + MenuTreeNode, + MenuTreeAdminResult, + MenuTreeActiveResult, + CreateMenuGroupInput, + CreateGroupInput, + UpdateNodeInput, + ReorderNodeInput, + DiscoveredMenu +} from "./types"; +import { DOMAIN_APP_PATHS } from "./types"; + +// 도메인별 전체 트리 조회 (관리 화면용) +export async function getMenuTreeForAdmin(domain: MenuDomain): Promise<MenuTreeAdminResult> { + const nodes = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.domain, domain)) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // 트리에 포함될 노드들: + // - menu_group (최상위 드롭다운) + // - group (드롭다운 내 그룹) + // - 배정된 menu (parentId !== null) + // - 최상위 menu (parentId === null, isActive === true) - 단일 링크 + const treeNodes = nodes.filter(n => + n.nodeType === 'menu_group' || + n.nodeType === 'group' || + (n.nodeType === 'menu' && n.parentId !== null) || + (n.nodeType === 'menu' && n.parentId === null && n.isActive) + ) as MenuTreeNode[]; + + const tree = buildTree(treeNodes); + + // 미배정 메뉴 (parentId가 null이고 isActive가 false인 menu) + const unassigned = nodes.filter(n => + n.nodeType === 'menu' && n.parentId === null && !n.isActive + ) as MenuTreeNode[]; + + return { tree, unassigned }; +} + +// 도메인별 활성 트리 조회 (헤더용) +export async function getActiveMenuTree(domain: MenuDomain): Promise<MenuTreeActiveResult> { + const nodes = await db.select() + .from(menuTreeNodes) + .where(and( + eq(menuTreeNodes.domain, domain), + eq(menuTreeNodes.isActive, true) + )) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // 트리에 포함될 노드들: + // - menu_group (최상위 드롭다운) + // - group (드롭다운 내 그룹) + // - 배정된 menu (parentId !== null) + // - 최상위 menu (parentId === null) - 단일 링크 + const treeNodes = nodes.filter(n => + n.nodeType === 'menu_group' || + n.nodeType === 'group' || + n.nodeType === 'menu' + ) as MenuTreeNode[]; + + const tree = buildTree(treeNodes); + + return { tree }; +} + +// 메뉴그룹 생성 (드롭다운) +export async function createMenuGroup(domain: MenuDomain, data: CreateMenuGroupInput) { + const [result] = await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu_group', + titleKo: data.titleKo, + titleEn: data.titleEn, + sortOrder: data.sortOrder ?? 0, + isActive: true, + }).returning(); + + revalidatePath('/evcp/menu-v2'); + return result; +} + +// 그룹 생성 (메뉴그룹 하위) +export async function createGroup(domain: MenuDomain, data: CreateGroupInput) { + const [result] = await db.insert(menuTreeNodes).values({ + domain, + parentId: data.parentId, + nodeType: 'group', + titleKo: data.titleKo, + titleEn: data.titleEn, + sortOrder: data.sortOrder ?? 0, + isActive: true, + }).returning(); + + revalidatePath('/evcp/menu-v2'); + return result; +} + +// 최상위 메뉴 생성 (단일 링크 - 기존 additional 역할) +export async function createTopLevelMenu(domain: MenuDomain, data: { + titleKo: string; + titleEn?: string; + menuPath: string; + sortOrder?: number; +}) { + const [result] = await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu', + titleKo: data.titleKo, + titleEn: data.titleEn, + menuPath: data.menuPath, + sortOrder: data.sortOrder ?? 0, + isActive: true, + }).returning(); + + revalidatePath('/evcp/menu-v2'); + return result; +} + +// 노드 이동 (드래그앤드롭) +export async function moveNode(nodeId: number, newParentId: number | null, newSortOrder: number) { + await db.update(menuTreeNodes) + .set({ + parentId: newParentId, + sortOrder: newSortOrder, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, nodeId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 노드 수정 +export async function updateNode(nodeId: number, data: UpdateNodeInput) { + await db.update(menuTreeNodes) + .set({ ...data, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 노드 삭제 +export async function deleteNode(nodeId: number) { + const [node] = await db.select().from(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)).limit(1); + + if (!node) return; + + if (node.nodeType === 'menu') { + // 최상위 메뉴(parentId === null)는 직접 삭제 가능 + // 하위 메뉴(parentId !== null)는 미배정으로 + if (node.parentId === null) { + await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)); + } else { + await db.update(menuTreeNodes) + .set({ parentId: null, isActive: false, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + } + } else { + // 메뉴그룹/그룹 삭제 시, 하위 메뉴는 미배정으로 + const children = await db.select({ id: menuTreeNodes.id, nodeType: menuTreeNodes.nodeType }) + .from(menuTreeNodes) + .where(eq(menuTreeNodes.parentId, nodeId)); + + for (const child of children) { + if (child.nodeType === 'menu') { + // 메뉴는 미배정으로 + await db.update(menuTreeNodes) + .set({ parentId: null, isActive: false, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, child.id)); + } else if (child.nodeType === 'group') { + // 그룹의 하위 메뉴도 미배정으로 + await db.update(menuTreeNodes) + .set({ parentId: null, isActive: false, updatedAt: new Date() }) + .where(eq(menuTreeNodes.parentId, child.id)); + + // 그룹 삭제 + await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, child.id)); + } + } + + // 본 노드 삭제 + await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)); + } + + revalidatePath('/evcp/menu-v2'); +} + +// 순서 일괄 변경 +export async function reorderNodes(updates: ReorderNodeInput[]) { + for (const { id, sortOrder } of updates) { + await db.update(menuTreeNodes) + .set({ sortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, id)); + } + revalidatePath('/evcp/menu-v2'); +} + +// 미배정 메뉴를 특정 그룹에 배정 +export async function assignMenuToGroup(menuId: number, groupId: number) { + await db.update(menuTreeNodes) + .set({ + parentId: groupId, + isActive: true, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, menuId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 미배정 메뉴를 최상위 메뉴로 활성화 +export async function activateAsTopLevelMenu(menuId: number) { + await db.update(menuTreeNodes) + .set({ + parentId: null, + isActive: true, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, menuId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 단일 노드 조회 +export async function getNodeById(nodeId: number): Promise<MenuTreeNode | null> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + return node as MenuTreeNode | null; +} + +// Helper: Convert flat list to tree +function buildTree(nodes: MenuTreeNode[]): MenuTreeNode[] { + const nodeMap = new Map<number, MenuTreeNode>(); + const roots: MenuTreeNode[] = []; + + nodes.forEach(node => { + nodeMap.set(node.id, { ...node, children: [] }); + }); + + nodes.forEach(node => { + const current = nodeMap.get(node.id)!; + if (node.parentId === null) { + roots.push(current); + } else { + const parent = nodeMap.get(node.parentId); + if (parent) { + if (!parent.children) parent.children = []; + parent.children.push(current); + } + } + }); + + const sortChildren = (nodes: MenuTreeNode[]) => { + nodes.sort((a, b) => a.sortOrder - b.sortOrder); + nodes.forEach(node => { + if (node.children?.length) { + sortChildren(node.children); + } + }); + }; + sortChildren(roots); + + return roots; +} + +// ============================================ +// Menu Discovery & Sync (Server Actions) +// ============================================ + +const DYNAMIC_SEGMENT_PATTERN = /^\[.+\]$/; + +/** + * Discover pages from app router for a specific domain + */ +function discoverMenusFromAppRouter(domain: MenuDomain): DiscoveredMenu[] { + const { appDir, basePath } = DOMAIN_APP_PATHS[domain]; + const menus: DiscoveredMenu[] = []; + + function scanDirectory(dir: string, currentPath: string[], routeGroup: string) { + const absoluteDir = path.resolve(process.cwd(), dir); + + if (!fs.existsSync(absoluteDir)) return; + + const entries = fs.readdirSync(absoluteDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(absoluteDir, entry.name); + + if (entry.isDirectory()) { + if (entry.name.startsWith('(') && entry.name.endsWith(')')) { + scanDirectory(fullPath, currentPath, entry.name); + } + else if (DYNAMIC_SEGMENT_PATTERN.test(entry.name)) { + continue; + } + else { + scanDirectory(fullPath, [...currentPath, entry.name], routeGroup); + } + } + else if (entry.name === 'page.tsx') { + const menuPath = basePath + (currentPath.length > 0 ? '/' + currentPath.join('/') : ''); + menus.push({ + domain, + menuPath, + pageFilePath: fullPath, + routeGroup + }); + } + } + } + + scanDirectory(appDir, [], ''); + return menus; +} + +/** + * Sync discovered menus for a specific domain + */ +export async function syncDiscoveredMenus(domain: MenuDomain): Promise<{ added: number; removed: number }> { + const discovered = discoverMenusFromAppRouter(domain); + + const existing = await db.select({ + id: menuTreeNodes.id, + menuPath: menuTreeNodes.menuPath + }) + .from(menuTreeNodes) + .where(and( + eq(menuTreeNodes.domain, domain), + inArray(menuTreeNodes.nodeType, ['menu', 'additional']) + )); + + const existingPaths = new Set(existing.map(e => e.menuPath).filter(Boolean)); + + const newMenus = discovered.filter(d => !existingPaths.has(d.menuPath)); + let added = 0; + + for (const menu of newMenus) { + const pathSegments = menu.menuPath.split('/').filter(Boolean); + const lastSegment = pathSegments[pathSegments.length - 1] || 'unknown'; + + await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu', + sortOrder: 0, + titleKo: lastSegment, + titleEn: lastSegment, + menuPath: menu.menuPath, + isActive: false, + }); + added++; + } + + revalidatePath('/evcp/menu-v2'); + return { added, removed: 0 }; +} + +/** + * Sync all domains + */ +export async function syncAllDomains(): Promise<Record<MenuDomain, { added: number; removed: number }>> { + const [evcp, partners] = await Promise.all([ + syncDiscoveredMenus('evcp'), + syncDiscoveredMenus('partners') + ]); + return { evcp, partners }; +} + +/** + * Get discovered menus without syncing + */ +export async function getDiscoveredMenus(): Promise<Record<MenuDomain, DiscoveredMenu[]>> { + return { + evcp: discoverMenusFromAppRouter('evcp'), + partners: discoverMenusFromAppRouter('partners') + }; +} + +// ============================================ +// Move Node Helpers +// ============================================ + +/** + * Move node up within same parent (decrease sort order) + */ +export async function moveNodeUp(nodeId: number): Promise<void> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + if (!node) return; + + // Get siblings (nodes with same parent) + const siblings = await db.select() + .from(menuTreeNodes) + .where(node.parentId === null + ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) + : eq(menuTreeNodes.parentId, node.parentId) + ) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // Find current index + const currentIndex = siblings.findIndex(s => s.id === nodeId); + if (currentIndex <= 0) return; // Already at top + + // Swap sort orders with previous node + const prevNode = siblings[currentIndex - 1]; + const prevSortOrder = prevNode.sortOrder; + const currentSortOrder = node.sortOrder; + + // If sort orders are the same, assign unique values + if (prevSortOrder === currentSortOrder) { + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex - 1, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, prevNode.id)); + } else { + await db.update(menuTreeNodes) + .set({ sortOrder: prevSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, prevNode.id)); + } + + revalidatePath('/evcp/menu-v2'); +} + +/** + * Move node down within same parent (increase sort order) + */ +export async function moveNodeDown(nodeId: number): Promise<void> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + if (!node) return; + + // Get siblings (nodes with same parent) + const siblings = await db.select() + .from(menuTreeNodes) + .where(node.parentId === null + ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) + : eq(menuTreeNodes.parentId, node.parentId) + ) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // Find current index + const currentIndex = siblings.findIndex(s => s.id === nodeId); + if (currentIndex >= siblings.length - 1) return; // Already at bottom + + // Swap sort orders with next node + const nextNode = siblings[currentIndex + 1]; + const nextSortOrder = nextNode.sortOrder; + const currentSortOrder = node.sortOrder; + + // If sort orders are the same, assign unique values + if (nextSortOrder === currentSortOrder) { + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex + 1, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nextNode.id)); + } else { + await db.update(menuTreeNodes) + .set({ sortOrder: nextSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nextNode.id)); + } + + revalidatePath('/evcp/menu-v2'); +} + +/** + * Move node to a different parent + */ +export async function moveNodeToParent(nodeId: number, newParentId: number | null): Promise<void> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + if (!node) return; + + // Get max sort order in new parent + const siblings = await db.select({ sortOrder: menuTreeNodes.sortOrder }) + .from(menuTreeNodes) + .where(newParentId === null + ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) + : eq(menuTreeNodes.parentId, newParentId) + ) + .orderBy(asc(menuTreeNodes.sortOrder)); + + const maxSortOrder = siblings.length > 0 ? Math.max(...siblings.map(s => s.sortOrder)) + 1 : 0; + + await db.update(menuTreeNodes) + .set({ + parentId: newParentId, + sortOrder: maxSortOrder, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, nodeId)); + + revalidatePath('/evcp/menu-v2'); +} + +/** + * Get all possible parent targets for a node (for Move To dialog) + * Returns items in tree order (same as Menu Structure display) + * + * Rules: + * - menu_group: Cannot be moved (always at top level) + * - group: Can only move to menu_group (not to root or other groups) + * - menu: Can move to root, menu_group, or group + */ +export async function getAvailableParents( + nodeId: number, + domain: MenuDomain, + nodeType: string +): Promise<{ id: number | null; title: string; depth: number }[]> { + const nodes = await db.select() + .from(menuTreeNodes) + .where(and( + eq(menuTreeNodes.domain, domain), + inArray(menuTreeNodes.nodeType, ['menu_group', 'group']) + )) + .orderBy(asc(menuTreeNodes.sortOrder)); + + const result: { id: number | null; title: string; depth: number }[] = []; + + // For menu nodes, allow moving to root (as top-level menu) + if (nodeType === 'menu') { + result.push({ id: null, title: 'Top Level (Root)', depth: 0 }); + } + + // Build tree structure + const nodeMap = new Map(nodes.map(n => [n.id, n])); + const menuGroups = nodes.filter(n => n.parentId === null && n.nodeType === 'menu_group'); + + // Helper to check if node is descendant of nodeId (prevent circular reference) + const isDescendantOf = (checkNode: typeof nodes[0], ancestorId: number): boolean => { + let parent = checkNode.parentId; + while (parent !== null) { + if (parent === ancestorId) return true; + const parentNode = nodeMap.get(parent); + parent = parentNode?.parentId ?? null; + } + return false; + }; + + // Traverse tree in order (menu_group -> its children groups) + for (const menuGroup of menuGroups) { + // Skip if it's the node being moved or its descendant + if (menuGroup.id === nodeId || isDescendantOf(menuGroup, nodeId)) continue; + + // Add menu_group + result.push({ + id: menuGroup.id, + title: menuGroup.titleKo, + depth: 1 + }); + + // For group nodes, only menu_groups are valid targets (skip children) + if (nodeType === 'group') continue; + + // Add children groups (sorted by sortOrder) + const childGroups = nodes + .filter(n => n.parentId === menuGroup.id && n.nodeType === 'group') + .sort((a, b) => a.sortOrder - b.sortOrder); + + for (const group of childGroups) { + // Skip if it's the node being moved or its descendant + if (group.id === nodeId || isDescendantOf(group, nodeId)) continue; + + result.push({ + id: group.id, + title: group.titleKo, + depth: 2 + }); + } + } + + return result; +} diff --git a/lib/menu-v2/types.ts b/lib/menu-v2/types.ts new file mode 100644 index 00000000..1be8a4fe --- /dev/null +++ b/lib/menu-v2/types.ts @@ -0,0 +1,103 @@ +// lib/menu-v2/types.ts + +export type NodeType = 'menu_group' | 'group' | 'menu' | 'additional'; +export type MenuDomain = 'evcp' | 'partners'; + +export interface MenuTreeNode { + id: number; + domain: MenuDomain; + parentId: number | null; + nodeType: NodeType; + sortOrder: number; + titleKo: string; + titleEn: string | null; + descriptionKo: string | null; + descriptionEn: string | null; + menuPath: string | null; + icon: string | null; + scrId: string | null; + isActive: boolean; + manager1Id: number | null; + manager2Id: number | null; + createdAt: Date; + updatedAt: Date; + // 조회 시 추가되는 필드 + children?: MenuTreeNode[]; +} + +export interface DiscoveredMenu { + domain: MenuDomain; + menuPath: string; + pageFilePath: string; + routeGroup: string; +} + +// 도메인별 앱 라우터 경로 설정 +export const DOMAIN_APP_PATHS: Record<MenuDomain, { + appDir: string; + basePath: string; +}> = { + evcp: { + appDir: 'app/[lng]/evcp/(evcp)', + basePath: '/evcp' + }, + partners: { + appDir: 'app/[lng]/partners', + basePath: '/partners' + } +}; + +// 관리자용 트리 조회 결과 타입 +// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합 +export interface MenuTreeAdminResult { + tree: MenuTreeNode[]; + unassigned: MenuTreeNode[]; +} + +// 헤더용 트리 조회 결과 타입 +// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합 +export interface MenuTreeActiveResult { + tree: MenuTreeNode[]; +} + +// 노드 생성 타입 +export interface CreateMenuGroupInput { + titleKo: string; + titleEn?: string; + sortOrder?: number; +} + +export interface CreateGroupInput { + parentId: number; + titleKo: string; + titleEn?: string; + sortOrder?: number; +} + +// 최상위 메뉴 생성 (단일 링크) +export interface CreateTopLevelMenuInput { + titleKo: string; + titleEn?: string; + menuPath: string; + sortOrder?: number; +} + +// 노드 업데이트 타입 +export interface UpdateNodeInput { + titleKo?: string; + titleEn?: string; + descriptionKo?: string; + descriptionEn?: string; + isActive?: boolean; + scrId?: string; + icon?: string; + manager1Id?: number | null; + manager2Id?: number | null; +} + +// 순서 변경 타입 +export interface ReorderNodeInput { + id: number; + sortOrder: number; +} + 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/sedp/get-tags-plant.ts b/lib/sedp/get-tags-plant.ts index be0e398b..f804ebe9 100644 --- a/lib/sedp/get-tags-plant.ts +++ b/lib/sedp/get-tags-plant.ts @@ -3,651 +3,578 @@ import { tagsPlant, formsPlant, formEntriesPlant, - items, - tagTypeClassFormMappings, projects, tagTypes, tagClasses, } from "@/db/schema"; -import { eq, and, like, inArray } from "drizzle-orm"; -import { revalidateTag } from "next/cache"; // 추가 +import { eq, and } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; import { getSEDPToken } from "./sedp-token"; -/** - * 태그 가져오기 서비스 함수 - * contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장 - * TAG_IDX를 기준으로 태그를 식별합니다. - * - * @param projectCode 계약 아이템 ID (contractItemId) - * @param packageCode 계약 아이템 ID (contractItemId) - * @param progressCallback 진행 상황을 보고하기 위한 콜백 함수 - * @returns 처리 결과 정보 (처리된 태그 수, 오류 목록 등) - */ +const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + +// ============ 타입 정의 ============ +interface newRegister { + PROJ_NO: string; + MAP_ID: string; + EP_ID: string; + DESC: string; + CATEGORY: string; + BYPASS: boolean; + REG_TYPE_ID: string; + TOOL_ID: string; + TOOL_TYPE: string; + SCOPES: string[]; + MAP_CLS: { + TOOL_ATT_NAME: string; + ITEMS: any[]; + }; + MAP_ATT: any[]; + MAP_TMPLS: string[]; + CRTER_NO: string; + CRTE_DTM: string; + CHGER_NO: string; + _id: string; +} + +interface Register { + PROJ_NO: string; + TYPE_ID: string; + EP_ID: string; + DESC: string; + REMARK: string | null; + NEW_TAG_YN: boolean; + ALL_TAG_YN: boolean; + VND_YN: boolean; + SEQ: number; + CMPLX_YN: boolean; + CMPL_SETT: any | null; + MAP_ATT: any[]; + MAP_CLS_ID: string[]; + MAP_OPER: any | null; + LNK_ATT: any[]; + JOIN_TABLS: any[]; + DELETED: boolean; + CRTER_NO: string; + CRTE_DTM: string; + CHGER_NO: string | null; + CHGE_DTM: string | null; + _id: string; +} + +interface FormInfo { + formCode: string; + formName: string; + im: boolean; + eng: boolean; +} + +// ============ API 호출 함수들 ============ + +async function getNewRegisters(projectCode: string): Promise<newRegister[]> { + const apiKey = await getSEDPToken(); + + const response = await fetch( + `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + TOOL_ID: "eVCP" + }) + } + ); + + if (!response.ok) { + throw new Error(`새 레지스터 요청 실패: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const registers: newRegister[] = Array.isArray(data) ? data : [data]; + + console.log(`[getNewRegisters] 프로젝트 ${projectCode}에서 ${registers.length}개의 레지스터를 가져왔습니다.`); + return registers; +} + +async function getRegisters(projectCode: string): Promise<Register[]> { + const apiKey = await getSEDPToken(); + + const response = await fetch( + `${SEDP_API_BASE_URL}/Register/Get`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + throw new Error(`레지스터 요청 실패: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const registers: Register[] = Array.isArray(data) ? data : [data]; + + console.log(`[getRegisters] 프로젝트 ${projectCode}에서 ${registers.length}개의 레지스터를 가져왔습니다.`); + return registers; +} + +async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> { + const apiKey = await getSEDPToken(); + + const response = await fetch( + `${SEDP_API_BASE_URL}/Data/GetPubData`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + REG_TYPE_ID: formCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SEDP API 요청 실패: ${response.status} ${response.statusText} - ${errorText}`); + } + + return await response.json(); +} + +async function getRegisterDetail(projectCode: string, formCode: string): Promise<Register | null> { + const apiKey = await getSEDPToken(); + + const response = await fetch( + `${SEDP_API_BASE_URL}/Register/GetByID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + TYPE_ID: formCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + console.error(`Register detail 요청 실패: ${formCode}`); + return null; + } + + return await response.json(); +} + +// ============ 메인 함수 ============ + export async function importTagsFromSEDP( projectCode: string, packageCode: string, - progressCallback?: (progress: number) => void, - mode?: string + progressCallback?: (progress: number) => void ): Promise<{ processedCount: number; excludedCount: number; totalEntries: number; errors?: string[]; }> { + const allErrors: string[] = []; + let totalProcessedCount = 0; + let totalExcludedCount = 0; + let totalEntriesCount = 0; + try { - // 진행 상황 보고 if (progressCallback) progressCallback(5); + // Step 1: 프로젝트 ID 조회 const project = await db.query.projects.findFirst({ where: eq(projects.code, projectCode), - columns: { - id: true - } + columns: { id: true } }); + if (!project) { + throw new Error(`Project not found: ${projectCode}`); + } + const projectId = project.id; + + if (progressCallback) progressCallback(10); - // 프로젝트 ID 획득 - const projectId = project?.id; + // Step 2: 두 API 동시 호출 + const [newRegisters, registers] = await Promise.all([ + getNewRegisters(projectCode), + getRegisters(projectCode) + ]); - // Step 1-2: Get the item using itemId from contractItem - const item = await db.query.items.findFirst({ - where: and(eq(items.ProjectNo, projectCode), eq(items.packageCode, packageCode)) + if (progressCallback) progressCallback(20); + + // ======== 서브클래스 매핑을 위한 태그 클래스 로드 ======== + const allTagClasses = await db.query.tagClasses.findMany({ + where: eq(tagClasses.projectId, projectId) }); - if (!item) { - throw new Error(`Item with ID ${item?.id} not found`); + // 클래스 코드로 빠른 조회를 위한 Map + const tagClassByCode = new Map(allTagClasses.map(tc => [tc.code, tc])); + + // 서브클래스 코드로 부모 클래스 찾기 위한 Map + const parentBySubclassCode = new Map<string, typeof allTagClasses[0]>(); + for (const tc of allTagClasses) { + if (tc.subclasses && Array.isArray(tc.subclasses)) { + for (const sub of tc.subclasses as { id: string; desc: string }[]) { + parentBySubclassCode.set(sub.id, tc); + } + } } - const itemCode = item.itemCode; + console.log(`[importTagsFromSEDP] 태그 클래스 ${allTagClasses.length}개 로드, 서브클래스 매핑 ${parentBySubclassCode.size}개 생성`); + // ======== 서브클래스 매핑 준비 완료 ======== - // 진행 상황 보고 - if (progressCallback) progressCallback(10); + // Step 3: packageCode에 해당하는 폼 정보 추출 + const formsToProcess: FormInfo[] = []; - // 기본 매핑 검색 - 모든 모드에서 사용 - const baseMappings = await db.query.tagTypeClassFormMappings.findMany({ - where: and( - like(tagTypeClassFormMappings.remark, `%${itemCode}%`), - eq(tagTypeClassFormMappings.projectId, projectId) - ) - }); - - if (baseMappings.length === 0) { - throw new Error(`No mapping found for item code ${itemCode}`); + // Register 정보를 Map으로 변환 (TYPE_ID로 빠른 조회) + const registerMap = new Map<string, Register>(); + for (const reg of registers) { + registerMap.set(reg.TYPE_ID, reg); } - // Step 2: Find the mapping entries - 모드에 따라 다른 조건 적용 - let mappings = []; - - if (mode === 'IM') { - // IM 모드일 때는 먼저 SEDP에서 태그 데이터를 가져와 TAG_TYPE_ID 리스트 확보 - - // 프로젝트 코드 가져오기 - const project = await db.query.projects.findFirst({ - where: eq(projects.id, projectId) - }); - - if (!project) { - throw new Error(`Project with ID ${projectId} not found`); - } - - // 각 매핑의 formCode에 대해 태그 데이터 조회 - const tagTypeIds = new Set<string>(); - - for (const mapping of baseMappings) { - try { - // SEDP에서 태그 데이터 가져오기 - const tagData = await fetchTagDataFromSEDP(project.code, mapping.formCode); - - // 첫 번째 키를 테이블 이름으로 사용 - const tableName = Object.keys(tagData)[0]; - const tagEntries = tagData[tableName]; - - if (Array.isArray(tagEntries)) { - // 모든 태그에서 TAG_TYPE_ID 수집 - for (const entry of tagEntries) { - if (entry.TAG_TYPE_ID && entry.TAG_TYPE_ID !== "") { - tagTypeIds.add(entry.TAG_TYPE_ID); - } - } - } - } catch (error) { - console.error(`Error fetching tag data for formCode ${mapping.formCode}:`, error); - } - } - - if (tagTypeIds.size === 0) { - throw new Error('No valid TAG_TYPE_ID found in SEDP tag data'); - } - - // 수집된 TAG_TYPE_ID로 tagTypes에서 정보 조회 - const tagTypeInfo = await db.query.tagTypes.findMany({ - where: and( - inArray(tagTypes.code, Array.from(tagTypeIds)), - eq(tagTypes.projectId, projectId) - ) - }); - - if (tagTypeInfo.length === 0) { - throw new Error('No matching tag types found for the collected TAG_TYPE_IDs'); - } - - // 태그 타입 설명 수집 - const tagLabels = tagTypeInfo.map(tt => tt.description); - - // IM 모드에 맞는 매핑 조회 - ep가 "IMEP"인 항목만 - mappings = await db.query.tagTypeClassFormMappings.findMany({ - where: and( - inArray(tagTypeClassFormMappings.tagTypeLabel, tagLabels), - eq(tagTypeClassFormMappings.projectId, projectId), - eq(tagTypeClassFormMappings.ep, "IMEP") - ) - }); - - } else { - // ENG 모드 또는 기본 모드일 때 - 기본 매핑 사용 - mappings = [...baseMappings]; - - // ENG 모드에서는 ep 필드가 "IMEP"가 아닌 매핑만 필터링 - if (mode === 'ENG') { - mappings = mappings.filter(mapping => mapping.ep !== "IMEP"); + // newRegisters에서 packageCode가 SCOPES에 포함된 것 필터링 + for (const newReg of newRegisters) { + if (newReg.SCOPES && newReg.SCOPES.includes(packageCode)) { + const formCode = newReg.REG_TYPE_ID; + const formName = newReg.DESC; + + // Register에서 EP_ID 확인하여 im/eng 결정 + const register = registerMap.get(formCode); + const isIM = register?.EP_ID === "IMEP"; + + formsToProcess.push({ + formCode, + formName, + im: isIM, + eng: !isIM + }); } } - // 매핑이 없는 경우 모드에 따라 다른 오류 메시지 사용 - if (mappings.length === 0) { - if (mode === 'IM') { - throw new Error('No suitable mappings found for IM mode'); - } else { - throw new Error(`No mapping found for item code ${itemCode}`); - } + if (formsToProcess.length === 0) { + throw new Error(`No forms found for packageCode: ${packageCode}`); } - - // 진행 상황 보고 - if (progressCallback) progressCallback(15); - - // 결과 누적을 위한 변수들 초기화 - let totalProcessedCount = 0; - let totalExcludedCount = 0; - let totalEntriesCount = 0; - const allErrors: string[] = []; - - // 각 매핑에 대해 처리 - for (let mappingIndex = 0; mappingIndex < mappings.length; mappingIndex++) { - const mapping = mappings[mappingIndex]; - - // Step 3: Get the project code - const project = await db.query.projects.findFirst({ - where: eq(projects.id, mapping.projectId) - }); - - if (!project) { - allErrors.push(`Project with ID ${mapping.projectId} not found`); - continue; // 다음 매핑으로 진행 - } - // IM 모드에서는 baseMappings에서 같은 formCode를 가진 매핑을 찾음 - let formCode = mapping.formCode; - if (mode === 'IM') { - // baseMapping에서 동일한 formCode를 가진 매핑 찾기 - const originalMapping = baseMappings.find( - baseMapping => baseMapping.formCode === mapping.formCode - ); - - // 찾았으면 해당 formCode 사용, 못 찾았으면 현재 매핑의 formCode 유지 - if (originalMapping) { - formCode = originalMapping.formCode; - } - } + console.log(`[importTagsFromSEDP] ${formsToProcess.length}개의 폼을 처리합니다.`); - // 진행 상황 보고 - 매핑별 진행률 조정 - if (progressCallback) { - const baseProgress = 15; - const mappingProgress = Math.floor(15 * (mappingIndex + 1) / mappings.length); - progressCallback(baseProgress + mappingProgress); - } + if (progressCallback) progressCallback(25); - // Step 4: Find the form ID - const form = await db.query.formsPlant.findFirst({ - where: and( - eq(formsPlant.projectCode, projectCode), - eq(formsPlant.formCode, formCode), - eq(formsPlant.packageCode, packageCode) - ) - }); - - let formId; - - // If form doesn't exist, create it - if (!form) { - // 폼이 없는 경우 새로 생성 - 모드에 따른 필드 설정 - const insertValues: any = { - projectCode, - packageCode, - formCode: formCode, - formName: mapping.formName - }; - - // 모드 정보가 있으면 해당 필드 설정 - if (mode) { - if (mode === "ENG") { - insertValues.eng = true; - } else if (mode === "IM") { - insertValues.im = true; - if (mapping.remark && mapping.remark.includes("VD_")) { - insertValues.eng = true; - } - } - } + // Step 4: 각 폼에 대해 처리 + for (let i = 0; i < formsToProcess.length; i++) { + const formInfo = formsToProcess[i]; + const { formCode, formName, im, eng } = formInfo; - const insertResult = await db.insert(formsPlant) - .values(insertValues) - .onConflictDoUpdate({ - target: [formsPlant.projectCode, formsPlant.formCode], - set: { - packageCode: insertValues.packageCode, - formName: insertValues.formName, - eng: insertValues.eng ?? false, - im: insertValues.im ?? false, - updatedAt: new Date() - } - }) - .returning({ id: formsPlant.id }); + try { + // 진행률 계산 + const baseProgress = 25; + const progressPerForm = 70 / formsToProcess.length; - if (insertResult.length === 0) { - allErrors.push(`Failed to create form record for formCode ${formCode}`); - continue; // 다음 매핑으로 진행 - } - - formId = insertResult[0].id; - } else { - // 폼이 이미 존재하는 경우 - 필요시 모드 필드 업데이트 - formId = form.id; - - if (mode) { - let shouldUpdate = false; - const updateValues: any = {}; - - if (mode === "ENG" && form.eng !== true) { - updateValues.eng = true; - shouldUpdate = true; - } else if (mode === "IM" && form.im !== true) { - updateValues.im = true; - shouldUpdate = true; - } - - if (shouldUpdate) { - await db.update(formsPlant) - .set({ - ...updateValues, - updatedAt: new Date() - }) - .where(eq(formsPlant.id, formId)); + // Step 4-1: formsPlant upsert + const existingForm = await db.query.formsPlant.findFirst({ + where: and( + eq(formsPlant.projectCode, projectCode), + eq(formsPlant.packageCode, packageCode), + eq(formsPlant.formCode, formCode) + ) + }); + + let formId: number; + + if (existingForm) { + // 기존 폼 업데이트 + await db.update(formsPlant) + .set({ + formName, + im, + eng, + updatedAt: new Date() + }) + .where(eq(formsPlant.id, existingForm.id)); + + formId = existingForm.id; + console.log(`[formsPlant] Updated form: ${formCode}`); + } else { + // 새 폼 생성 + const insertResult = await db.insert(formsPlant) + .values({ + projectCode, + packageCode, + formCode, + formName, + im, + eng + }) + .returning({ id: formsPlant.id }); - console.log(`Updated form ${formId} with ${mode} mode enabled`); - } + formId = insertResult[0].id; + console.log(`[formsPlant] Created form: ${formCode}`); } - } - - // 진행 상황 보고 - 매핑별 진행률 조정 - if (progressCallback) { - const baseProgress = 30; - const mappingProgress = Math.floor(20 * (mappingIndex + 1) / mappings.length); - progressCallback(baseProgress + mappingProgress); - } - try { - // Step 5: Call the external API to get tag data - const tagData = await fetchTagDataFromSEDP(projectCode, baseMappings[0].formCode); - - // 진행 상황 보고 if (progressCallback) { - const baseProgress = 50; - const mappingProgress = Math.floor(10 * (mappingIndex + 1) / mappings.length); - progressCallback(baseProgress + mappingProgress); + progressCallback(baseProgress + progressPerForm * (i + 0.2)); } - // Step 6: Process the data and insert into the tags table - let processedCount = 0; - let excludedCount = 0; - - // Get the first key from the response as the table name + // Step 4-2: SEDP에서 태그 데이터 가져오기 + const tagData = await fetchTagDataFromSEDP(projectCode, formCode); const tableName = Object.keys(tagData)[0]; const tagEntries = tagData[tableName]; if (!Array.isArray(tagEntries) || tagEntries.length === 0) { - allErrors.push(`No tag data found in the API response for formCode ${baseMappings[0].formCode}`); - continue; // 다음 매핑으로 진행 + console.log(`[importTagsFromSEDP] No tag data for formCode: ${formCode}`); + continue; } - const entriesCount = tagEntries.length; - totalEntriesCount += entriesCount; - - // formEntries를 위한 데이터 수집 - const newTagsForFormEntry: Array<{ - TAG_IDX: string; // 변경: TAG_NO → TAG_IDX - TAG_NO?: string; // TAG_NO도 함께 저장 (편집 가능한 필드) - TAG_DESC: string | null; - status: string; - [key: string]: any; - }> = []; - const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; - const apiKey = await getSEDPToken(); - - const registerResponse = await fetch( - `${SEDP_API_BASE_URL}/Register/GetByID`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'accept': '*/*', - 'ApiKey': apiKey, - 'ProjectNo': projectCode - }, - body: JSON.stringify({ - ProjectNo: projectCode, - TYPE_ID: baseMappings[0].formCode, // 또는 mapping.formCode - ContainDeleted: false - }) - } - ) - - if (!registerResponse.ok) { - allErrors.push(`Failed to fetch register details for ${baseMappings[0].formCode}`) - continue + totalEntriesCount += tagEntries.length; + + if (progressCallback) { + progressCallback(baseProgress + progressPerForm * (i + 0.4)); } - - const registerDetail: Register = await registerResponse.json() + + // Step 4-3: Register detail에서 허용된 ATT_ID 추출 + const registerDetail = await getRegisterDetail(projectCode, formCode); + const allowedAttIds = new Set<string>(); - // ✅ MAP_ATT에서 허용된 ATT_ID 목록 추출 - const allowedAttIds = new Set<string>() - if (Array.isArray(registerDetail.MAP_ATT)) { + if (registerDetail?.MAP_ATT && Array.isArray(registerDetail.MAP_ATT)) { for (const mapAttr of registerDetail.MAP_ATT) { if (mapAttr.ATT_ID) { - allowedAttIds.add(mapAttr.ATT_ID) + allowedAttIds.add(mapAttr.ATT_ID); } } } - - // Process each tag entry - for (let i = 0; i < tagEntries.length; i++) { - try { - const entry = tagEntries[i]; - - // TAG_IDX가 없는 경우 제외 (변경: TAG_NO → TAG_IDX 체크) - if (!entry.TAG_IDX) { - excludedCount++; - totalExcludedCount++; - - // 주기적으로 진행 상황 보고 (건너뛰어도 진행률은 업데이트) - if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { - const baseProgress = 60; - const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount))); - progressCallback(baseProgress + entryProgress); - } + // Step 4-4: 태그 처리 + const newTagsForFormEntry: Array<Record<string, any>> = []; + let processedCount = 0; + let excludedCount = 0; - continue; // 이 항목은 건너뜀 - } + for (const entry of tagEntries) { + // TAG_IDX 없으면 제외 + if (!entry.TAG_IDX) { + excludedCount++; + continue; + } + + // TAG_TYPE_ID 없으면 제외 + if (!entry.TAG_TYPE_ID || entry.TAG_TYPE_ID === "") { + excludedCount++; + continue; + } - const attributes: Record<string, string> = {} - if (Array.isArray(entry.ATTRIBUTES)) { - for (const attr of entry.ATTRIBUTES) { - // MAP_ATT에 정의된 ATT_ID만 포함 - if (attr.ATT_ID && allowedAttIds.has(attr.ATT_ID)) { - if (attr.VALUE !== null && attr.VALUE !== undefined) { - attributes[attr.ATT_ID] = String(attr.VALUE) - } + // attributes 추출 (허용된 ATT_ID만) + const attributes: Record<string, string> = {}; + if (Array.isArray(entry.ATTRIBUTES)) { + for (const attr of entry.ATTRIBUTES) { + if (attr.ATT_ID && allowedAttIds.has(attr.ATT_ID)) { + if (attr.VALUE !== null && attr.VALUE !== undefined) { + attributes[attr.ATT_ID] = String(attr.VALUE); } } } - - - // TAG_TYPE_ID가 null이거나 빈 문자열인 경우 제외 - if (entry.TAG_TYPE_ID === null || entry.TAG_TYPE_ID === "") { - excludedCount++; - totalExcludedCount++; - - // 주기적으로 진행 상황 보고 (건너뛰어도 진행률은 업데이트) - if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { - const baseProgress = 60; - const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount))); - progressCallback(baseProgress + entryProgress); - } + } - continue; // 이 항목은 건너뜀 + // tagType 조회 + const tagType = await db.query.tagTypes.findFirst({ + where: and( + eq(tagTypes.code, entry.TAG_TYPE_ID), + eq(tagTypes.projectId, projectId) + ) + }); + + // ======== 클래스 및 서브클래스 결정 로직 ======== + let classLabel: string; + let subclassValue: string | null = null; + let tagClassId: number | null = null; + + // 1. 먼저 CLS_ID로 직접 tagClass 찾기 + const tagClass = tagClassByCode.get(entry.CLS_ID); + + if (tagClass) { + // 직접 찾은 경우 - 이게 메인 클래스 + classLabel = tagClass.label || entry.CLS_ID; + tagClassId = tagClass.id; + } else { + // 2. 서브클래스인지 확인 (부모 클래스의 subclasses 배열에 있는지) + const parentClass = parentBySubclassCode.get(entry.CLS_ID); + + if (parentClass) { + // 서브클래스인 경우 + classLabel = parentClass.label || parentClass.code; + subclassValue = entry.CLS_ID; + tagClassId = parentClass.id; + + console.log(`[importTagsFromSEDP] 서브클래스 발견: ${entry.CLS_ID} -> 부모: ${parentClass.code}`); + } else { + // 어디에도 없는 경우 - 원본 값 사용 + classLabel = entry.CLS_ID; + console.log(`[importTagsFromSEDP] 클래스를 찾을 수 없음: ${entry.CLS_ID}`); } - - // Get tag type description - const tagType = await db.query.tagTypes.findFirst({ - where: and( - eq(tagTypes.code, entry.TAG_TYPE_ID), - eq(tagTypes.projectId, mapping.projectId) - ) - }); - - // Get tag class label - const tagClass = await db.query.tagClasses.findFirst({ - where: and( - eq(tagClasses.code, entry.CLS_ID), - eq(tagClasses.projectId, mapping.projectId) - ) - }); - - // Insert or update the tag - tagIdx 필드 추가 - await db.insert(tagsPlant).values({ - projectCode, - packageCode, - formId: formId, - tagIdx: entry.TAG_IDX, + } + // ======== 클래스/서브클래스 결정 완료 ======== + + // tagsPlant upsert (subclass 필드 추가) + await db.insert(tagsPlant).values({ + projectCode, + packageCode, + formId, + tagIdx: entry.TAG_IDX, + tagNo: entry.TAG_NO || entry.TAG_IDX, + tagType: tagType?.description || entry.TAG_TYPE_ID, + tagClassId: tagClassId, + class: classLabel, + subclass: subclassValue, + description: entry.TAG_DESC, + attributes, + }).onConflictDoUpdate({ + target: [tagsPlant.projectCode, tagsPlant.packageCode, tagsPlant.tagIdx], + set: { + formId, tagNo: entry.TAG_NO || entry.TAG_IDX, tagType: tagType?.description || entry.TAG_TYPE_ID, - tagClassId: tagClass?.id, - class: tagClass?.label || entry.CLS_ID, + tagClassId: tagClassId, + class: classLabel, + subclass: subclassValue, description: entry.TAG_DESC, - attributes: attributes, // JSONB로 저장 - }).onConflictDoUpdate({ - target: [tagsPlant.projectCode, tagsPlant.packageCode, tagsPlant.tagIdx], - set: { - formId: formId, - tagNo: entry.TAG_NO || entry.TAG_IDX, - tagType: tagType?.description || entry.TAG_TYPE_ID, - class: tagClass?.label || entry.CLS_ID, - description: entry.TAG_DESC, - attributes: attributes, // JSONB 업데이트 - updatedAt: new Date() - } - }) - // formEntries용 데이터 수집 - const tagDataForFormEntry = { - TAG_IDX: entry.TAG_IDX, // 변경: TAG_NO → TAG_IDX - TAG_NO: entry.TAG_NO || entry.TAG_IDX, // TAG_NO도 함께 저장 - TAG_DESC: entry.TAG_DESC || null, - status: "From S-EDP", // SEDP에서 가져온 데이터임을 표시 - source: "S-EDP" // 태그 출처 (불변) - S-EDP에서 가져옴 - }; - - // ATTRIBUTES가 있으면 추가 (SHI 필드들) - if (Array.isArray(entry.ATTRIBUTES)) { - for (const attr of entry.ATTRIBUTES) { - if (attr.ATT_ID && attr.VALUE !== null && attr.VALUE !== undefined) { - tagDataForFormEntry[attr.ATT_ID] = attr.VALUE; - } + attributes, + updatedAt: new Date() + } + }); + + // formEntriesPlant용 데이터 준비 + const tagDataForFormEntry: Record<string, any> = { + TAG_IDX: entry.TAG_IDX, + TAG_NO: entry.TAG_NO || entry.TAG_IDX, + TAG_DESC: entry.TAG_DESC || null, + status: "From S-EDP", + source: "S-EDP" + }; + + // ATTRIBUTES 추가 + if (Array.isArray(entry.ATTRIBUTES)) { + for (const attr of entry.ATTRIBUTES) { + if (attr.ATT_ID && attr.VALUE !== null && attr.VALUE !== undefined) { + tagDataForFormEntry[attr.ATT_ID] = attr.VALUE; } } + } - newTagsForFormEntry.push(tagDataForFormEntry); + newTagsForFormEntry.push(tagDataForFormEntry); + processedCount++; + } - processedCount++; - totalProcessedCount++; + totalProcessedCount += processedCount; + totalExcludedCount += excludedCount; - // 주기적으로 진행 상황 보고 - if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { - const baseProgress = 60; - const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount))); - progressCallback(baseProgress + entryProgress); - } - } catch (error: any) { - console.error(`Error processing tag entry:`, error); - allErrors.push(error.message || 'Unknown error'); - } + if (progressCallback) { + progressCallback(baseProgress + progressPerForm * (i + 0.8)); } - // Step 7: formEntries 업데이트 - TAG_IDX 기준으로 변경 + // Step 4-5: formEntriesPlant upsert if (newTagsForFormEntry.length > 0) { - try { - // 기존 formEntry 가져오기 - const existingEntry = await db.query.formEntriesPlant.findFirst({ - where: and( - eq(formEntriesPlant.formCode, formCode), - eq(formEntriesPlant.projectCode, projectCode), - eq(formEntriesPlant.packageCode, packageCode) - ) - }); - - if (existingEntry && existingEntry.id) { - // 기존 formEntry가 있는 경우 - let existingData: Array<{ - TAG_IDX?: string; // 추가: TAG_IDX 필드 - TAG_NO?: string; - TAG_DESC?: string; - status?: string; - [key: string]: any; - }> = []; - - if (Array.isArray(existingEntry.data)) { - existingData = existingEntry.data; - } + const existingEntry = await db.query.formEntriesPlant.findFirst({ + where: and( + eq(formEntriesPlant.formCode, formCode), + eq(formEntriesPlant.projectCode, projectCode), + eq(formEntriesPlant.packageCode, packageCode) + ) + }); + + if (existingEntry) { + // 기존 데이터 병합 + let existingData: Array<Record<string, any>> = []; + if (Array.isArray(existingEntry.data)) { + existingData = existingEntry.data; + } - // 기존 TAG_IDX들 추출 (변경: TAG_NO → TAG_IDX) - const existingTagIdxs = new Set( - existingData - .map(item => item.TAG_IDX) - .filter(tagIdx => tagIdx !== undefined && tagIdx !== null) - ); + const existingTagIdxs = new Set( + existingData.map(item => item.TAG_IDX).filter(Boolean) + ); - // 중복되지 않은 새 태그들만 필터링 (변경: TAG_NO → TAG_IDX) - const newUniqueTagsData = newTagsForFormEntry.filter( - tagData => !existingTagIdxs.has(tagData.TAG_IDX) + // 기존 데이터 업데이트 + 새 데이터 추가 + const updatedData = existingData.map(existingItem => { + const newData = newTagsForFormEntry.find( + n => n.TAG_IDX === existingItem.TAG_IDX ); + return newData ? { ...existingItem, ...newData } : existingItem; + }); - // 기존 태그들의 status와 ATTRIBUTES 업데이트 (변경: TAG_NO → TAG_IDX) - const updatedExistingData = existingData.map(existingItem => { - const newTagData = newTagsForFormEntry.find( - newItem => newItem.TAG_IDX === existingItem.TAG_IDX - ); - - if (newTagData) { - // 기존 태그가 있으면 SEDP 데이터로 업데이트 - return { - ...existingItem, - ...newTagData, - TAG_IDX: existingItem.TAG_IDX // TAG_IDX는 유지 - }; - } - - return existingItem; - }); - - const finalData = [...updatedExistingData, ...newUniqueTagsData]; + const newUniqueData = newTagsForFormEntry.filter( + n => !existingTagIdxs.has(n.TAG_IDX) + ); - await db - .update(formEntriesPlant) - .set({ - data: finalData, - updatedAt: new Date() - }) - .where(eq(formEntriesPlant.id, existingEntry.id)); + await db.update(formEntriesPlant) + .set({ + data: [...updatedData, ...newUniqueData], + updatedAt: new Date() + }) + .where(eq(formEntriesPlant.id, existingEntry.id)); - console.log(`[IMPORT SEDP] Updated formEntry with ${newUniqueTagsData.length} new tags, updated ${updatedExistingData.length - newUniqueTagsData.length} existing tags for form ${formCode}`); - } else { - // formEntry가 없는 경우 새로 생성 - await db.insert(formEntriesPlant).values({ - formCode: formCode, - projectCode, - packageCode, - data: newTagsForFormEntry, - createdAt: new Date(), - updatedAt: new Date(), - }); - - console.log(`[IMPORT SEDP] Created new formEntry with ${newTagsForFormEntry.length} tags for form ${formCode}`); - } + console.log(`[formEntriesPlant] Updated: ${formCode} (${newUniqueData.length} new, ${updatedData.length} updated)`); + } else { + // 새로 생성 + await db.insert(formEntriesPlant).values({ + formCode, + projectCode, + packageCode, + data: newTagsForFormEntry, + createdAt: new Date(), + updatedAt: new Date() + }); - // 캐시 무효화 - // revalidateTag(`form-data-${formCode}-${packageId}`); - } catch (formEntryError) { - console.error(`[IMPORT SEDP] Error updating formEntry for form ${formCode}:`, formEntryError); - allErrors.push(`Error updating formEntry for form ${formCode}: ${formEntryError}`); + console.log(`[formEntriesPlant] Created: ${formCode} (${newTagsForFormEntry.length} tags)`); } } + if (progressCallback) { + progressCallback(baseProgress + progressPerForm * (i + 1)); + } + } catch (error: any) { - console.error(`Error processing mapping for formCode ${formCode}:`, error); - allErrors.push(`Error with formCode ${formCode}: ${error.message || 'Unknown error'}`); + console.error(`Error processing form ${formCode}:`, error); + allErrors.push(`Form ${formCode}: ${error.message}`); } } - // 모든 매핑 처리 완료 - 진행률 100% - if (progressCallback) { - progressCallback(100); - } + if (progressCallback) progressCallback(100); - // 최종 결과 반환 return { processedCount: totalProcessedCount, excludedCount: totalExcludedCount, totalEntries: totalEntriesCount, errors: allErrors.length > 0 ? allErrors : undefined }; + } catch (error: any) { console.error("Tag import error:", error); throw error; } -} - -/** - * SEDP API에서 태그 데이터 가져오기 - * - * @param projectCode 프로젝트 코드 - * @param formCode 양식 코드 - * @returns API 응답 데이터 - */ -async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> { - try { - // Get the token - const apiKey = await getSEDPToken(); - - // Define the API base URL - const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; - - // Make the API call - const response = await fetch( - `${SEDP_API_BASE_URL}/Data/GetPubData`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'accept': '*/*', - 'ApiKey': apiKey, - 'ProjectNo': projectCode - }, - body: JSON.stringify({ - ProjectNo: projectCode, - REG_TYPE_ID: formCode, - ContainDeleted: false - }) - } - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); - } - - const data = await response.json(); - return data; - } catch (error: any) { - console.error('Error calling SEDP API:', error); - throw new Error(`Failed to fetch data from SEDP API: ${error.message || 'Unknown error'}`); - } }
\ No newline at end of file diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts index 9bdd238d..4cdaf90d 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는 제거되었습니다. @@ -255,11 +256,14 @@ export async function mapECCBiddingHeaderToBidding( // prNumber: 대표 PR의 BANFN 또는 타겟 PR의 ZREQ_FN 값 prNumber = representativeItem?.BANFN || targetItem.ZREQ_FN || null; } + + // 원입찰번호(originalBiddingNumber)에 생성된 biddingNumber에서 '-'로 split해서 앞부분을 사용 + const originalBiddingNumber = biddingNumber ? biddingNumber.split('-')[0] : (eccHeader.ANFNR || null); // 매핑 const mappedData: BiddingData = { biddingNumber, // 생성된 Bidding 코드 - originalBiddingNumber: eccHeader.ANFNR || null, // 원입찰번호 + originalBiddingNumber, // 원입찰번호에 생성된 biddingnumber split 결과 사용 revision: 0, // 기본 리비전 0 (I/F 해서 가져온 건 보낸 적 없으므로 0 고정) projectName, // 타겟 PR Item의 PSPID로 찾은 프로젝트 이름 itemName, // 타겟 PR Item 정보로 조회한 자재명/내역 @@ -278,7 +282,6 @@ export async function mapECCBiddingHeaderToBidding( biddingRegistrationDate: new Date().toISOString(), // 입찰등록일 I/F 시점 등록(1120 이시원 프로 요청) submissionStartDate: null, submissionEndDate: null, - evaluationDate: null, // 사양설명회 hasSpecificationMeeting: false, // 기본값 처리하고, 입찰관리상세에서 사용자가 관리 @@ -293,6 +296,7 @@ export async function mapECCBiddingHeaderToBidding( // PR 정보 prNumber, // 첫번째 PR의 ZREQ_FN 값 hasPrDocument: false, // PR문서는 POS를 말하는 것으로 보임. + plant: eccHeader.WERKS || null, // 플랜트 코드(WERKS) // 상태 및 설정 status: 'bidding_generated', // 입찰생성 상태 @@ -497,6 +501,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, }); diff --git a/lib/soap/ecc/send/chemical-substance-check.ts b/lib/soap/ecc/send/chemical-substance-check.ts new file mode 100644 index 00000000..b5c4cc25 --- /dev/null +++ b/lib/soap/ecc/send/chemical-substance-check.ts @@ -0,0 +1,449 @@ +'use server' + +import { sendSoapXml } from "@/lib/soap/sender"; +import type { SoapSendConfig, SoapLogInfo, SoapSendResult } from "@/lib/soap/types"; + +// ECC 화학물질 조회 엔드포인트 (WSDL에 명시된 인터페이스 사용) +const ECC_CHEMICAL_SUBSTANCE_ENDPOINT = "http://shii8dvddb01.hec.serp.shi.samsung.net:50000/sap/xi/engine?type=entry&version=3.0&Sender.Service=P2038_Q&Interface=http%3A%2F%2Fshi.samsung.co.kr%2FP2_MM%2FMMM%5E[P2MM_INTERFACE_NAME]"; + +// 화학물질 조회 요청 데이터 타입 +export interface ChemicalSubstanceCheckRequest { + T_LIST: Array<{ + BUKRS: string; // Company Code (M, CHAR 4) + WERKS: string; // Plant (M, CHAR 4) + LIFNR: string; // Vendor's account number (M, CHAR 10) + MATNR: string; // Material Number (M, CHAR 18) + }>; +} + +// 화학물질 조회 응답 데이터 타입 +export interface ChemicalSubstanceCheckResponse { + T_LIST: Array<{ + QINSPST: string; // Y/N (화학물질 여부) + SGTXT: string; // Text (상세 메시지) + }>; +} + +// 화학물질 조회 결과 타입 (DB 저장용) +export interface ChemicalSubstanceResult { + bukrs: string; + werks: string; + lifnr: string; + matnr: string; + hasChemicalSubstance: boolean; + message: string; + checkedAt: Date; +} + +// SOAP Body Content 생성 함수 +function createChemicalSubstanceCheckSoapBodyContent(data: ChemicalSubstanceCheckRequest): Record<string, unknown> { + return { + 'p1:MT_[INTERFACE_NAME]_S': { // 실제 인터페이스명으로 변경 필요 + 'T_LIST': data.T_LIST + } + }; +} + +// 화학물질 조회 데이터 검증 함수 +function validateChemicalSubstanceCheckData(data: ChemicalSubstanceCheckRequest): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + // T_LIST 배열 검증 + if (!data.T_LIST || !Array.isArray(data.T_LIST) || data.T_LIST.length === 0) { + errors.push('T_LIST는 필수이며 최소 1개 이상의 데이터가 있어야 합니다.'); + } else { + data.T_LIST.forEach((item, index) => { + // 필수 필드 검증 + if (!item.BUKRS || typeof item.BUKRS !== 'string' || item.BUKRS.trim() === '') { + errors.push(`T_LIST[${index}].BUKRS은 필수입니다.`); + } else if (item.BUKRS.length > 4) { + errors.push(`T_LIST[${index}].BUKRS은 4자를 초과할 수 없습니다.`); + } + + if (!item.WERKS || typeof item.WERKS !== 'string' || item.WERKS.trim() === '') { + errors.push(`T_LIST[${index}].WERKS는 필수입니다.`); + } else if (item.WERKS.length > 4) { + errors.push(`T_LIST[${index}].WERKS는 4자를 초과할 수 없습니다.`); + } + + if (!item.LIFNR || typeof item.LIFNR !== 'string' || item.LIFNR.trim() === '') { + errors.push(`T_LIST[${index}].LIFNR은 필수입니다.`); + } else if (item.LIFNR.length > 10) { + errors.push(`T_LIST[${index}].LIFNR은 10자를 초과할 수 없습니다.`); + } + + if (!item.MATNR || typeof item.MATNR !== 'string' || item.MATNR.trim() === '') { + errors.push(`T_LIST[${index}].MATNR은 필수입니다.`); + } else if (item.MATNR.length > 18) { + errors.push(`T_LIST[${index}].MATNR은 18자를 초과할 수 없습니다.`); + } + }); + } + + return { + isValid: errors.length === 0, + errors + }; +} + +// ECC로 화학물질 조회 SOAP XML 전송하는 함수 +async function sendChemicalSubstanceCheckToECC(data: ChemicalSubstanceCheckRequest): Promise<SoapSendResult> { + try { + // 데이터 검증 + const validation = validateChemicalSubstanceCheckData(data); + if (!validation.isValid) { + return { + success: false, + message: `데이터 검증 실패: ${validation.errors.join(', ')}` + }; + } + + // SOAP Body Content 생성 + const soapBodyContent = createChemicalSubstanceCheckSoapBodyContent(data); + + // SOAP 전송 설정 + const config: SoapSendConfig = { + endpoint: ECC_CHEMICAL_SUBSTANCE_ENDPOINT, + envelope: soapBodyContent, + soapAction: 'http://sap.com/xi/WebService/soap1.1', + timeout: 30000, // 화학물질 조회는 30초 타임아웃 + retryCount: 3, + retryDelay: 1000, + namespace: 'http://shi.samsung.co.kr/P2_MM/MMM', // ECC MM 모듈 네임스페이스 + prefix: 'p1' // WSDL에서 사용하는 p1 접두사 + }; + + // 로그 정보 + const logInfo: SoapLogInfo = { + direction: 'OUTBOUND', + system: 'S-ERP ECC', + interface: 'IF_ECC_EVCP_CHEMICAL_SUBSTANCE_CHECK' + }; + + const materials = data.T_LIST.map(item => `${item.BUKRS}/${item.WERKS}/${item.LIFNR}/${item.MATNR}`).join(', '); + console.log(`📤 화학물질 조회 요청 전송 시작 - Materials: ${materials}`); + console.log(`🔍 조회 대상 물질 ${data.T_LIST.length}개`); + + // SOAP XML 전송 + const result = await sendSoapXml(config, logInfo); + + if (result.success) { + console.log(`✅ 화학물질 조회 요청 전송 성공 - Materials: ${materials}`); + } else { + console.error(`❌ 화학물질 조회 요청 전송 실패 - Materials: ${materials}, 오류: ${result.message}`); + } + + return result; + + } catch (error) { + console.error('❌ 화학물질 조회 전송 중 오류 발생:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +// ======================================== +// 메인 화학물질 조회 서버 액션 함수들 +// ======================================== + +// 단일 화학물질 조회 요청 처리 +export async function checkChemicalSubstance(params: { + bukrs: string; + werks: string; + lifnr: string; + matnr: string; +}): Promise<{ + success: boolean; + message: string; + hasChemicalSubstance?: boolean; + responseData?: string; + statusCode?: number; + headers?: Record<string, string>; + endpoint?: string; + requestXml?: string; + material?: string; +}> { + try { + console.log(`🚀 화학물질 조회 요청 시작 - Material: ${params.bukrs}/${params.werks}/${params.lifnr}/${params.matnr}`); + + const requestData: ChemicalSubstanceCheckRequest = { + T_LIST: [{ + BUKRS: params.bukrs, + WERKS: params.werks, + LIFNR: params.lifnr, + MATNR: params.matnr + }] + }; + + const result = await sendChemicalSubstanceCheckToECC(requestData); + + let hasChemicalSubstance: boolean | undefined; + let message = result.message; + + if (result.success && result.responseText) { + try { + // 응답 파싱 로직 (실제 응답 구조에 따라 조정 필요) + // QINSPST = 'Y' 이면 화학물질 있음, 'N'이면 없음 + const responseData = JSON.parse(result.responseText); + if (responseData?.T_LIST?.[0]) { + const item = responseData.T_LIST[0]; + hasChemicalSubstance = item.QINSPST === 'Y'; + message = item.SGTXT || result.message; + } + } catch (parseError) { + console.warn('응답 데이터 파싱 실패:', parseError); + } + } + + return { + success: result.success, + message, + hasChemicalSubstance, + responseData: result.responseText, + statusCode: result.statusCode, + headers: result.headers, + endpoint: result.endpoint, + requestXml: result.requestXml, + material: `${params.bukrs}/${params.werks}/${params.lifnr}/${params.matnr}` + }; + + } catch (error) { + console.error('❌ 화학물질 조회 요청 처리 실패:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +// 여러 물질 배치 화학물질 조회 요청 처리 +export async function checkMultipleChemicalSubstances(items: Array<{ + bukrs: string; + werks: string; + lifnr: string; + matnr: string; +}>): Promise<{ + success: boolean; + message: string; + results?: Array<{ + material: string; + hasChemicalSubstance?: boolean; + success: boolean; + error?: string; + message?: string; + }>; +}> { + try { + console.log(`🚀 배치 화학물질 조회 요청 시작: ${items.length}개`); + + const requestData: ChemicalSubstanceCheckRequest = { + T_LIST: items.map(item => ({ + BUKRS: item.bukrs, + WERKS: item.werks, + LIFNR: item.lifnr, + MATNR: item.matnr + })) + }; + + const result = await sendChemicalSubstanceCheckToECC(requestData); + + let results: Array<{ + material: string; + hasChemicalSubstance?: boolean; + success: boolean; + error?: string; + message?: string; + }> | undefined; + + if (result.success && result.responseText) { + try { + const responseData = JSON.parse(result.responseText); + if (responseData?.T_LIST && Array.isArray(responseData.T_LIST)) { + results = responseData.T_LIST.map((item: any, index: number) => { + const originalItem = items[index]; + const material = `${originalItem.bukrs}/${originalItem.werks}/${originalItem.lifnr}/${originalItem.matnr}`; + + return { + material, + hasChemicalSubstance: item.QINSPST === 'Y', + success: true, + message: item.SGTXT + }; + }); + } + } catch (parseError) { + console.warn('배치 응답 데이터 파싱 실패:', parseError); + // 파싱 실패시 전체 실패로 처리 + results = items.map(item => ({ + material: `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`, + success: false, + error: '응답 데이터 파싱 실패' + })); + } + } else { + // 전송 실패시 모든 항목 실패로 처리 + results = items.map(item => ({ + material: `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`, + success: false, + error: result.message + })); + } + + const successCount = results?.filter(r => r.success).length || 0; + const failCount = (results?.length || 0) - successCount; + + console.log(`🎉 배치 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`); + + return { + success: result.success, + message: result.success + ? `배치 화학물질 조회 성공: ${successCount}개` + : `배치 화학물질 조회 실패: ${result.message}`, + results + }; + + } catch (error) { + console.error('❌ 배치 화학물질 조회 중 전체 오류 발생:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +// 개별 처리 방식의 배치 화학물질 조회 (각각 따로 전송) +export async function checkMultipleChemicalSubstancesIndividually(items: Array<{ + bukrs: string; + werks: string; + lifnr: string; + matnr: string; +}>): Promise<{ + success: boolean; + message: string; + results?: Array<{ + material: string; + hasChemicalSubstance?: boolean; + success: boolean; + error?: string; + message?: string; + }>; +}> { + try { + console.log(`🚀 개별 화학물질 조회 요청 시작: ${items.length}개`); + + const results: Array<{ + material: string; + hasChemicalSubstance?: boolean; + success: boolean; + error?: string; + message?: string; + }> = []; + + for (const item of items) { + try { + const material = `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`; + console.log(`📤 화학물질 조회 처리 중: ${material}`); + + const checkResult = await checkChemicalSubstance(item); + + results.push({ + material, + hasChemicalSubstance: checkResult.hasChemicalSubstance, + success: checkResult.success, + error: checkResult.success ? undefined : checkResult.message, + message: checkResult.message + }); + + // 개별 처리간 지연 (시스템 부하 방지) + if (items.length > 1) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + + } catch (error) { + const material = `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`; + console.error(`❌ 화학물질 조회 처리 실패: ${material}`, error); + results.push({ + material, + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + + const successCount = results.filter(r => r.success).length; + const failCount = results.length - successCount; + + console.log(`🎉 개별 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`); + + return { + success: failCount === 0, + message: `개별 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`, + results + }; + + } catch (error) { + console.error('❌ 개별 화학물질 조회 중 전체 오류 발생:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +// 테스트용 화학물질 조회 함수 (샘플 데이터 포함) +export async function checkTestChemicalSubstance(): Promise<{ + success: boolean; + message: string; + hasChemicalSubstance?: boolean; + responseData?: string; + testData?: ChemicalSubstanceCheckRequest; +}> { + try { + console.log('🧪 테스트용 화학물질 조회 시작'); + + // 테스트용 샘플 데이터 생성 + const testData: ChemicalSubstanceCheckRequest = { + T_LIST: [{ + BUKRS: '1000', + WERKS: '1000', + LIFNR: 'TEST_VENDOR', + MATNR: 'TEST_MATERIAL' + }] + }; + + const result = await sendChemicalSubstanceCheckToECC(testData); + + let hasChemicalSubstance: boolean | undefined; + let message = result.message; + + if (result.success && result.responseText) { + try { + const responseData = JSON.parse(result.responseText); + if (responseData?.T_LIST?.[0]) { + const item = responseData.T_LIST[0]; + hasChemicalSubstance = item.QINSPST === 'Y'; + message = item.SGTXT || result.message; + } + } catch (parseError) { + console.warn('테스트 응답 데이터 파싱 실패:', parseError); + } + } + + return { + success: result.success, + message, + hasChemicalSubstance, + responseData: result.responseText, + testData + }; + + } catch (error) { + console.error('❌ 테스트 화학물질 조회 실패:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} diff --git a/lib/tags-plant/queries.ts b/lib/tags-plant/queries.ts index a0d28b1e..c7ad43e0 100644 --- a/lib/tags-plant/queries.ts +++ b/lib/tags-plant/queries.ts @@ -5,6 +5,7 @@ import db from "@/db/db" import { tagsPlant } from "@/db/schema/vendorData" import { eq, and } from "drizzle-orm" +import { revalidateTag, unstable_noStore } from "next/cache"; /** * 모든 태그 가져오기 (클라이언트 렌더링용) @@ -13,6 +14,7 @@ export async function getAllTagsPlant( projectCode: string, packageCode: string ) { + unstable_noStore(); try { const tags = await db .select() diff --git a/lib/tags-plant/service.ts b/lib/tags-plant/service.ts index 9e9d9ebf..27cc207b 100644 --- a/lib/tags-plant/service.ts +++ b/lib/tags-plant/service.ts @@ -25,6 +25,14 @@ interface CreatedOrExistingForm { isNewlyCreated: boolean; } +interface FormInfo { + formCode: string; + formName: string; + im: boolean; + eng: boolean; +} + + /** * 16진수 24자리 고유 식별자 생성 * @returns 24자리 16진수 문자열 (예: "a1b2c3d4e5f6789012345678") @@ -280,6 +288,7 @@ export async function createTag( tagIdx: generatedTagIdx, // 🆕 생성된 16진수 24자리 추가 tagNo: validated.data.tagNo, class: validated.data.class, + subclass: validated.data.subclass, tagType: validated.data.tagType, description: validated.data.description ?? null, }) @@ -1790,13 +1799,11 @@ export async function getIMForms( return existingForms } - // 2. DB에 없으면 SEDP API에서 가져오기 + // 2. DB에 없으면 두 API 동시 호출 const apiKey = await getSEDPToken() - // 2-1. GetByToolID로 레지스터 매핑 정보 가져오기 - const mappingResponse = await fetch( - `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, - { + const [newRegistersResponse, registersResponse] = await Promise.all([ + fetch(`${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1808,95 +1815,94 @@ export async function getIMForms( ProjectNo: projectCode, TOOL_ID: "eVCP" }) - } - ) + }), + fetch(`${SEDP_API_BASE_URL}/Register/Get`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + ContainDeleted: false + }) + }) + ]) - if (!mappingResponse.ok) { - throw new Error( - `레지스터 매핑 요청 실패: ${mappingResponse.status} ${mappingResponse.statusText}` - ) + if (!newRegistersResponse.ok) { + throw new Error(`새 레지스터 요청 실패: ${newRegistersResponse.status}`) } - const mappingData = await mappingResponse.json() - const registers: NewRegister[] = Array.isArray(mappingData) - ? mappingData - : [mappingData] + if (!registersResponse.ok) { + throw new Error(`레지스터 요청 실패: ${registersResponse.status}`) + } - // 2-2. packageCode가 SCOPES에 포함된 레지스터 필터링 - const matchingRegisters = registers.filter(register => - register.SCOPES.includes(packageCode) - ) + const newRegistersData = await newRegistersResponse.json() + const registersData = await registersResponse.json() - if (matchingRegisters.length === 0) { - console.log(`패키지 ${packageCode}에 해당하는 레지스터가 없습니다.`) - return [] + const newRegisters: NewRegister[] = Array.isArray(newRegistersData) + ? newRegistersData + : [newRegistersData] + + const registers: RegisterDetail[] = Array.isArray(registersData) + ? registersData + : [registersData] + + // 3. Register를 Map으로 변환 (TYPE_ID로 빠른 조회) + const registerMap = new Map<string, RegisterDetail>() + for (const reg of registers) { + registerMap.set(reg.TYPE_ID, reg) } - // 2-3. 각 레지스터의 상세 정보 가져오기 + // 4. packageCode가 SCOPES에 포함되고, EP_ID가 "IMEP"인 것만 필터링 const formInfos: FormInfo[] = [] const formsToInsert: typeof formsPlant.$inferInsert[] = [] - for (const register of matchingRegisters) { - try { - const detailResponse = await fetch( - `${SEDP_API_BASE_URL}/Register/GetByID`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'accept': '*/*', - 'ApiKey': apiKey, - 'ProjectNo': projectCode - }, - body: JSON.stringify({ - ProjectNo: projectCode, - TYPE_ID: register.REG_TYPE_ID, - ContainDeleted: false - }) - } - ) - - if (!detailResponse.ok) { - console.error( - `레지스터 상세 정보 요청 실패 (${register.REG_TYPE_ID}): ${detailResponse.status}` - ) - continue - } - - const detail: RegisterDetail = await detailResponse.json() + for (const newReg of newRegisters) { + // packageCode가 SCOPES에 없으면 스킵 + if (!newReg.SCOPES || !newReg.SCOPES.includes(packageCode)) { + continue + } - // DELETED가 true이거나 DESC가 없으면 스킵 - if (detail.DELETED || !detail.DESC) { - continue - } + const formCode = newReg.REG_TYPE_ID + const register = registerMap.get(formCode) - formInfos.push({ - formCode: detail.TYPE_ID, - formName: detail.DESC - }) + // Register에서 EP_ID가 "IMEP"가 아니면 스킵 (IM 폼만 처리) + if (!register || register.EP_ID !== "IMEP") { + continue + } - // DB 삽입용 데이터 준비 - formsToInsert.push({ - projectCode: projectCode, - packageCode: packageCode, - formCode: detail.TYPE_ID, - formName: detail.DESC, - eng: false, - im: true - }) - } catch (error) { - console.error( - `레지스터 ${register.REG_TYPE_ID} 상세 정보 가져오기 실패:`, - error - ) + // DELETED면 스킵 + if (register.DELETED) { continue } + + const formName = newReg.DESC || register.DESC || formCode + + formInfos.push({ + formCode, + formName + }) + + formsToInsert.push({ + projectCode, + packageCode, + formCode, + formName, + eng: false, + im: true + }) } - // 2-4. DB에 저장 + // 5. DB에 저장 if (formsToInsert.length > 0) { - await db.insert(formsPlant).values(formsToInsert).onConflictDoNothing() - console.log(`${formsToInsert.length}개의 IM 폼을 DB에 저장했습니다.`) + await db.insert(formsPlant) + .values(formsToInsert) + .onConflictDoNothing() + + console.log(`[getIMForms] ${formsToInsert.length}개의 IM 폼을 DB에 저장했습니다.`) } return formInfos diff --git a/lib/tags-plant/table/add-tag-dialog.tsx b/lib/tags-plant/table/add-tag-dialog.tsx index de5d2bf8..1bfb0703 100644 --- a/lib/tags-plant/table/add-tag-dialog.tsx +++ b/lib/tags-plant/table/add-tag-dialog.tsx @@ -329,7 +329,7 @@ export function AddTagDialog({ projectCode, packageCode }: AddTagDialogProps) { const tagData: CreateTagSchema = { tagType: data.tagType, class: data.class, - // subclass: data.subclass, // 서브클래스 정보 추가 + subclass: data.subclass, // 서브클래스 정보 추가 tagNo: row.tagNo, description: row.description, ...Object.fromEntries( diff --git a/lib/tags-plant/table/tag-table-column.tsx b/lib/tags-plant/table/tag-table-column.tsx index 80c25464..30bdacc3 100644 --- a/lib/tags-plant/table/tag-table-column.tsx +++ b/lib/tags-plant/table/tag-table-column.tsx @@ -82,14 +82,27 @@ export function getColumns({ minSize: 150, size: 240, }, - { + { accessorKey: "class", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Tag Class" /> + <DataTableColumnHeaderSimple column={column} title="Class" /> ), cell: ({ row }) => <div>{row.getValue("class")}</div>, meta: { - excelHeader: "Tag Class" + excelHeader: "Class" + }, + enableResizing: true, + minSize: 100, + size: 150, + }, + { + accessorKey: "subclass", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Item Class" /> + ), + cell: ({ row }) => <div>{row.getValue("subclass")}</div>, + meta: { + excelHeader: "Item Class" }, enableResizing: true, minSize: 100, diff --git a/lib/tags-plant/table/tag-table.tsx b/lib/tags-plant/table/tag-table.tsx index 2fdcd5fc..70bfc4e4 100644 --- a/lib/tags-plant/table/tag-table.tsx +++ b/lib/tags-plant/table/tag-table.tsx @@ -78,6 +78,9 @@ export function TagsTable({ const [isLoading, setIsLoading] = React.useState(true) const [rowAction, setRowAction] = React.useState<DataTableRowAction<Tag> | null>(null) + + console.log(tableData,"tableData") + // 선택된 행 관리 const [selectedRowsData, setSelectedRowsData] = React.useState<Tag[]>([]) const [clearSelection, setClearSelection] = React.useState(false) diff --git a/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx b/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx new file mode 100644 index 00000000..bd53b3cc --- /dev/null +++ b/lib/tech-vendors/possible-items/connect-item-vendor-dialog.tsx @@ -0,0 +1,406 @@ +"use client"; + +import * as React from "react"; +import { Search, X } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + getItemsForVendorMapping, + getConnectableVendorsForItem, + connectItemWithVendors, +} from "../service"; + +type ItemType = "SHIP" | "TOP" | "HULL"; + +interface ItemData { + id: number; + itemCode: string | null; + itemList: string | null; + workType: string | null; + shipTypes?: string | null; + subItemList?: string | null; + itemType: ItemType; + createdAt: Date; + updatedAt: Date; +} + +interface VendorData { + id: number; + vendorName: string; + email: string | null; + techVendorType: string; + status: string; +} + +interface ConnectItemVendorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConnected?: () => void; +} + +export function ConnectItemVendorDialog({ + open, + onOpenChange, + onConnected, +}: ConnectItemVendorDialogProps) { + const [items, setItems] = React.useState<ItemData[]>([]); + const [filteredItems, setFilteredItems] = React.useState<ItemData[]>([]); + const [itemSearch, setItemSearch] = React.useState(""); + const [selectedItem, setSelectedItem] = React.useState<ItemData | null>(null); + + const [vendors, setVendors] = React.useState<VendorData[]>([]); + const [filteredVendors, setFilteredVendors] = React.useState<VendorData[]>([]); + const [vendorSearch, setVendorSearch] = React.useState(""); + const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([]); + + const [isLoadingItems, setIsLoadingItems] = React.useState(false); + const [isLoadingVendors, setIsLoadingVendors] = React.useState(false); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + // 다이얼로그가 열릴 때 전체 아이템 목록 로드 + React.useEffect(() => { + if (open) { + loadItems(); + } + }, [open]); + + // 아이템 검색 필터링 + React.useEffect(() => { + if (!itemSearch) { + setFilteredItems(items); + return; + } + + const lowered = itemSearch.toLowerCase(); + const filtered = items.filter((item) => + [item.itemCode, item.itemList, item.workType, item.shipTypes, item.subItemList] + .filter(Boolean) + .some((value) => value?.toLowerCase().includes(lowered)) + ); + setFilteredItems(filtered); + }, [items, itemSearch]); + + // 벤더 검색 필터링 + React.useEffect(() => { + if (!vendorSearch) { + setFilteredVendors(vendors); + return; + } + + const lowered = vendorSearch.toLowerCase(); + const filtered = vendors.filter((vendor) => + [vendor.vendorName, vendor.email, vendor.techVendorType, vendor.status] + .filter(Boolean) + .some((value) => value?.toLowerCase().includes(lowered)) + ); + setFilteredVendors(filtered); + }, [vendors, vendorSearch]); + + // 특정 아이템 선택 시 연결 가능한 벤더 목록 로드 + React.useEffect(() => { + if (!selectedItem) { + setVendors([]); + setFilteredVendors([]); + setSelectedVendorIds([]); + return; + } + loadVendors(selectedItem); + }, [selectedItem]); + + const loadItems = async () => { + setIsLoadingItems(true); + try { + const result = await getItemsForVendorMapping(); + if (result.error) { + throw new Error(result.error); + } + const validItems = (result.data as ItemData[]).filter((item) => item.itemCode != null); + setItems(validItems); + } catch (error) { + console.error("Failed to load items for mapping:", error); + toast.error("아이템 목록을 불러오는데 실패했습니다."); + } finally { + setIsLoadingItems(false); + } + }; + + const loadVendors = async (item: ItemData) => { + setIsLoadingVendors(true); + try { + const result = await getConnectableVendorsForItem(item.id, item.itemType); + if (result.error) { + throw new Error(result.error); + } + setVendors(result.data as VendorData[]); + } catch (error) { + console.error("Failed to load vendors for item:", error); + toast.error("연결 가능한 벤더 목록을 불러오는데 실패했습니다."); + } finally { + setIsLoadingVendors(false); + } + }; + + const handleItemSelect = (item: ItemData) => { + if (!item.itemCode) return; + setSelectedItem(item); + }; + + const handleVendorToggle = (vendorId: number) => { + setSelectedVendorIds((prev) => + prev.includes(vendorId) + ? prev.filter((id) => id !== vendorId) + : [...prev, vendorId] + ); + }; + + const handleSubmit = async () => { + if (!selectedItem || selectedVendorIds.length === 0) return; + + setIsSubmitting(true); + try { + const result = await connectItemWithVendors({ + itemId: selectedItem.id, + itemType: selectedItem.itemType, + vendorIds: selectedVendorIds, + }); + + if (!result.success) { + throw new Error(result.error || "연결에 실패했습니다."); + } + + const successCount = result.successCount || 0; + const skippedCount = result.skipped?.length || 0; + + toast.success( + `${successCount}개 벤더와 연결되었습니다${ + skippedCount > 0 ? ` (${skippedCount}개 중복 제외)` : "" + }` + ); + + onConnected?.(); + handleClose(); + } catch (error) { + console.error("Failed to connect item with vendors:", error); + toast.error(error instanceof Error ? error.message : "연결 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + setTimeout(() => { + setItemSearch(""); + setVendorSearch(""); + setSelectedItem(null); + setSelectedVendorIds([]); + setItems([]); + setFilteredItems([]); + setVendors([]); + setFilteredVendors([]); + }, 200); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>아이템 기준 벤더 연결</DialogTitle> + <DialogDescription> + 연결할 아이템을 먼저 선택한 후, 해당 아이템과 연결할 벤더를 선택하세요. + </DialogDescription> + </DialogHeader> + + <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0"> + {/* 아이템 선택 영역 */} + <div className="flex flex-col space-y-3"> + <div className="space-y-2"> + <Label htmlFor="item-search">아이템 검색</Label> + <div className="relative"> + <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> + <Input + id="item-search" + placeholder="아이템코드, 아이템리스트, 공종, 선종 검색..." + value={itemSearch} + onChange={(e) => setItemSearch(e.target.value)} + className="pl-10" + /> + </div> + </div> + + {selectedItem && ( + <div className="space-y-2"> + <Label>선택된 아이템</Label> + <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50"> + <Badge variant="default" className="text-xs"> + {[selectedItem.itemType, selectedItem.itemCode, selectedItem.shipTypes] + .filter(Boolean) + .join("-")} + <X + className="ml-1 h-3 w-3 cursor-pointer" + onClick={(e) => { + e.stopPropagation(); + setSelectedItem(null); + }} + /> + </Badge> + </div> + </div> + )} + + <div className="flex-1 min-h-0 overflow-hidden"> + <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2 h-full"> + {isLoadingItems ? ( + <div className="text-center py-4">아이템 로딩 중...</div> + ) : filteredItems.length === 0 ? ( + <div className="text-center py-4 text-muted-foreground"> + 아이템이 없습니다. + </div> + ) : ( + <div className="space-y-2"> + {filteredItems.map((item) => { + if (!item.itemCode) return null; + const isSelected = selectedItem?.id === item.id && selectedItem.itemType === item.itemType; + const itemKey = `${item.itemType}-${item.id}-${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ""}`; + return ( + <div + key={`item-${itemKey}`} + className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${ + isSelected + ? "bg-primary/10 border-primary hover:bg-primary/20" + : "hover:bg-gray-50" + }`} + onClick={() => handleItemSelect(item)} + > + <div className="font-medium"> + {[`[${item.itemType}]`, item.itemCode, item.shipTypes] + .filter(Boolean) + .join(" ")} + </div> + <div className="text-sm text-muted-foreground"> + {item.itemList || "-"} + </div> + <div className="flex flex-wrap gap-2 mt-1 text-xs"> + <span>공종: {item.workType || "-"}</span> + {item.shipTypes && <span>선종: {item.shipTypes}</span>} + {item.subItemList && <span>서브아이템: {item.subItemList}</span>} + </div> + </div> + ); + })} + </div> + )} + </div> + </div> + </div> + + {/* 벤더 선택 영역 */} + <div className="flex flex-col space-y-3"> + <div className="space-y-2"> + <Label htmlFor="vendor-search">벤더 검색</Label> + <div className="relative"> + <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> + <Input + id="vendor-search" + placeholder="벤더명, 이메일, 벤더타입, 상태로 검색..." + value={vendorSearch} + onChange={(e) => setVendorSearch(e.target.value)} + className="pl-10" + disabled={!selectedItem} + /> + </div> + </div> + + {selectedVendorIds.length > 0 && ( + <div className="space-y-2"> + <Label>선택된 벤더 ({selectedVendorIds.length}개)</Label> + <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50 max-h-20 overflow-y-auto"> + {vendors + .filter((vendor) => selectedVendorIds.includes(vendor.id)) + .map((vendor) => ( + <Badge key={`selected-vendor-${vendor.id}`} variant="default" className="text-xs"> + {vendor.vendorName} + <X + className="ml-1 h-3 w-3 cursor-pointer" + onClick={(e) => { + e.stopPropagation(); + handleVendorToggle(vendor.id); + }} + /> + </Badge> + ))} + </div> + </div> + )} + + <div className="flex-1 min-h-0 overflow-hidden"> + <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2 h-full"> + {!selectedItem ? ( + <div className="text-center py-4 text-muted-foreground"> + 아이템을 먼저 선택해주세요. + </div> + ) : isLoadingVendors ? ( + <div className="text-center py-4">벤더 로딩 중...</div> + ) : filteredVendors.length === 0 ? ( + <div className="text-center py-4 text-muted-foreground"> + 연결 가능한 벤더가 없습니다. + </div> + ) : ( + <div className="space-y-2"> + {filteredVendors.map((vendor) => { + const isSelected = selectedVendorIds.includes(vendor.id); + return ( + <div + key={`vendor-${vendor.id}`} + className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${ + isSelected + ? "bg-primary/10 border-primary hover:bg-primary/20" + : "hover:bg-gray-50" + }`} + onClick={() => handleVendorToggle(vendor.id)} + > + <div className="font-medium">{vendor.vendorName}</div> + <div className="text-sm text-muted-foreground"> + {vendor.email || "-"} + </div> + <div className="flex flex-wrap gap-2 mt-1 text-xs"> + <span>타입: {vendor.techVendorType || "-"}</span> + <span>상태: {vendor.status || "-"}</span> + </div> + </div> + ); + })} + </div> + )} + </div> + </div> + </div> + </div> + + <div className="flex justify-end gap-2 pt-4 border-t"> + <Button variant="outline" onClick={handleClose}> + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={!selectedItem || selectedVendorIds.length === 0 || isSubmitting} + > + {isSubmitting ? "연결 중..." : `연결 (${selectedVendorIds.length})`} + </Button> + </div> + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/tech-vendors/possible-items/possible-items-table.tsx b/lib/tech-vendors/possible-items/possible-items-table.tsx index 100ef04a..226cddf7 100644 --- a/lib/tech-vendors/possible-items/possible-items-table.tsx +++ b/lib/tech-vendors/possible-items/possible-items-table.tsx @@ -37,7 +37,8 @@ import { getTechVendorPossibleItems } from "../../tech-vendor-possible-items/ser import { deleteTechVendorPossibleItem, getTechVendorDetailById } from "../service" import type { TechVendorPossibleItem } from "../validations" import { PossibleItemsTableToolbarActions } from "./possible-items-toolbar-actions" -import { AddItemDialog } from "./add-item-dialog" // 주석처리 +import { AddItemDialog } from "./add-item-dialog" +import { ConnectItemVendorDialog } from "./connect-item-vendor-dialog" interface TechVendorPossibleItemsTableProps { promises: Promise< @@ -55,7 +56,8 @@ export function TechVendorPossibleItemsTable({ // Suspense로 받아온 데이터 const [{ data, pageCount }] = React.use(promises) const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorPossibleItem> | null>(null) - const [showAddDialog, setShowAddDialog] = React.useState(false) // 주석처리 + const [showAddDialog, setShowAddDialog] = React.useState(false) + const [showConnectDialog, setShowConnectDialog] = React.useState(false) const [showDeleteAlert, setShowDeleteAlert] = React.useState(false) const [isDeleting, setIsDeleting] = React.useState(false) @@ -189,7 +191,8 @@ export function TechVendorPossibleItemsTable({ <PossibleItemsTableToolbarActions table={table} vendorId={vendorId} - onAdd={() => setShowAddDialog(true)} // 주석처리 + onAdd={() => setShowAddDialog(true)} + onConnect={() => setShowConnectDialog(true)} onRefresh={() => { // 페이지 새로고침을 위한 콜백 window.location.reload() @@ -199,13 +202,20 @@ export function TechVendorPossibleItemsTable({ </DataTableAdvancedToolbar> </DataTable> - {/* Add Item Dialog */} + {/* Add Item Dialog (벤더 기준) */} <AddItemDialog open={showAddDialog} onOpenChange={setShowAddDialog} vendorId={vendorId} /> + {/* Item -> Vendor Connect Dialog (아이템 기준) */} + <ConnectItemVendorDialog + open={showConnectDialog} + onOpenChange={setShowConnectDialog} + onConnected={() => window.location.reload()} + /> + {/* Vendor Items Dialog */} <Dialog open={showItemsDialog} onOpenChange={setShowItemsDialog}> <DialogContent className="max-w-2xl max-h-[80vh]"> diff --git a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx index 49a673ff..428f4ce5 100644 --- a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx +++ b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx @@ -39,14 +39,16 @@ import { interface PossibleItemsTableToolbarActionsProps { table: Table<TechVendorPossibleItem> vendorId: number - onAdd: () => void // 주석처리 + onAdd: () => void + onConnect: () => void onRefresh?: () => void // 데이터 새로고침 콜백 } export function PossibleItemsTableToolbarActions({ table, vendorId, - onAdd, // 주석처리 + onAdd, + onConnect, onRefresh, }: PossibleItemsTableToolbarActionsProps) { const [showDeleteAlert, setShowDeleteAlert] = React.useState(false) @@ -362,7 +364,16 @@ export function PossibleItemsTableToolbarActions({ onClick={onAdd} > <Plus className="mr-2 h-4 w-4" /> - 아이템 연결 + 기존 아이템 연결 + </Button> + + <Button + variant="outline" + size="sm" + onClick={onConnect} + > + <Plus className="mr-2 h-4 w-4" /> + 아이템-벤더 연결 </Button> {selectedRows.length > 0 && ( diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts index 940e59ce..5290b6a0 100644 --- a/lib/tech-vendors/service.ts +++ b/lib/tech-vendors/service.ts @@ -703,6 +703,267 @@ export interface ItemDropdownOption { subItemList: string | null; } +export interface ItemForVendorMapping { + id: number; + itemCode: string | null; + itemList: string | null; + workType: string | null; + shipTypes?: string | null; + subItemList?: string | null; + itemType: "SHIP" | "TOP" | "HULL"; + createdAt: Date; + updatedAt: Date; +} + +export interface VendorForItemMapping { + id: number; + vendorName: string; + email: string | null; + techVendorType: string; + status: string; +} + +const itemTypeToVendorType: Record<"SHIP" | "TOP" | "HULL", string> = { + SHIP: "조선", + TOP: "해양TOP", + HULL: "해양HULL", +}; + +function parseVendorTypes(value: string | string[] | null) { + if (!value) return [] as string[]; + if (Array.isArray(value)) { + return value + .map((type) => type.trim()) + .filter((type) => type.length > 0); + } + return value + .split(",") + .map((type) => type.trim()) + .filter((type) => type.length > 0); +} + +/** + * 아이템 기준으로 벤더 매핑 시 사용할 전체 아이템 목록 조회 + * 벤더에 관계없이 전 타입을 모두 가져온다. + */ +export async function getItemsForVendorMapping() { + return unstable_cache( + async () => { + try { + const items: ItemForVendorMapping[] = []; + + const shipbuildingItems = await db + .select({ + id: itemShipbuilding.id, + createdAt: itemShipbuilding.createdAt, + updatedAt: itemShipbuilding.updatedAt, + itemCode: itemShipbuilding.itemCode, + itemList: itemShipbuilding.itemList, + workType: itemShipbuilding.workType, + shipTypes: itemShipbuilding.shipTypes, + }) + .from(itemShipbuilding) + .orderBy(asc(itemShipbuilding.itemCode)); + + items.push( + ...shipbuildingItems + .filter((item) => item.itemCode != null) + .map((item) => ({ + ...item, + itemType: "SHIP" as const, + })) + ); + + const offshoreTopItems = await db + .select({ + id: itemOffshoreTop.id, + createdAt: itemOffshoreTop.createdAt, + updatedAt: itemOffshoreTop.updatedAt, + itemCode: itemOffshoreTop.itemCode, + itemList: itemOffshoreTop.itemList, + workType: itemOffshoreTop.workType, + subItemList: itemOffshoreTop.subItemList, + }) + .from(itemOffshoreTop) + .orderBy(asc(itemOffshoreTop.itemCode)); + + items.push( + ...offshoreTopItems + .filter((item) => item.itemCode != null) + .map((item) => ({ + ...item, + itemType: "TOP" as const, + })) + ); + + const offshoreHullItems = await db + .select({ + id: itemOffshoreHull.id, + createdAt: itemOffshoreHull.createdAt, + updatedAt: itemOffshoreHull.updatedAt, + itemCode: itemOffshoreHull.itemCode, + itemList: itemOffshoreHull.itemList, + workType: itemOffshoreHull.workType, + subItemList: itemOffshoreHull.subItemList, + }) + .from(itemOffshoreHull) + .orderBy(asc(itemOffshoreHull.itemCode)); + + items.push( + ...offshoreHullItems + .filter((item) => item.itemCode != null) + .map((item) => ({ + ...item, + itemType: "HULL" as const, + })) + ); + + return { data: items, error: null }; + } catch (err) { + console.error("Failed to fetch items for vendor mapping:", err); + return { + data: [], + error: "아이템 목록을 불러오는데 실패했습니다.", + }; + } + }, + ["items-for-vendor-mapping"], + { + revalidate: 3600, + tags: ["items"], + } + )(); +} + +/** + * 특정 아이템에 연결 가능한 벤더 목록을 조회 + * - 이미 연결된 벤더는 제외 + * - 아이템 타입과 벤더 타입(조선/해양TOP/해양HULL) 매칭 + */ +export async function getConnectableVendorsForItem( + itemId: number, + itemType: "SHIP" | "TOP" | "HULL" +) { + unstable_noStore(); + + try { + // 1) 이미 연결된 벤더 ID 조회 + const existingVendors = await db + .select({ vendorId: techVendorPossibleItems.vendorId }) + .from(techVendorPossibleItems) + .where( + itemType === "SHIP" + ? eq(techVendorPossibleItems.shipbuildingItemId, itemId) + : itemType === "TOP" + ? eq(techVendorPossibleItems.offshoreTopItemId, itemId) + : eq(techVendorPossibleItems.offshoreHullItemId, itemId) + ); + + const existingVendorIds = existingVendors.map((row) => row.vendorId); + + // 2) 모든 벤더 조회 후 타입 매칭 + 중복 제외 + const vendorRows = await db + .select({ + id: techVendors.id, + vendorName: techVendors.vendorName, + email: techVendors.email, + techVendorType: techVendors.techVendorType, + status: techVendors.status, + }) + .from(techVendors); + + const targetType = itemTypeToVendorType[itemType]; + + const availableVendors: VendorForItemMapping[] = vendorRows + .map((vendor) => ({ + ...vendor, + vendorTypes: parseVendorTypes(vendor.techVendorType), + })) + .filter( + (vendor) => + vendor.vendorTypes.includes(targetType) && + !existingVendorIds.includes(vendor.id) + ) + .map(({ vendorTypes, ...rest }) => rest); + + return { data: availableVendors, error: null }; + } catch (err) { + console.error("Failed to fetch connectable vendors:", err); + return { data: [], error: "연결 가능한 벤더 조회에 실패했습니다." }; + } +} + +/** + * 선택한 아이템을 여러 벤더와 연결 + * - 중복 연결은 건너뜀 + */ +export async function connectItemWithVendors(input: { + itemId: number; + itemType: "SHIP" | "TOP" | "HULL"; + vendorIds: number[]; +}) { + unstable_noStore(); + + if (!input.vendorIds || input.vendorIds.length === 0) { + return { success: false, error: "연결할 벤더를 선택해주세요." }; + } + + try { + let successCount = 0; + const skipped: number[] = []; + + await db.transaction(async (tx) => { + for (const vendorId of input.vendorIds) { + const whereConditions = [eq(techVendorPossibleItems.vendorId, vendorId)]; + + if (input.itemType === "SHIP") { + whereConditions.push(eq(techVendorPossibleItems.shipbuildingItemId, input.itemId)); + } else if (input.itemType === "TOP") { + whereConditions.push(eq(techVendorPossibleItems.offshoreTopItemId, input.itemId)); + } else { + whereConditions.push(eq(techVendorPossibleItems.offshoreHullItemId, input.itemId)); + } + + const existing = await tx.query.techVendorPossibleItems.findFirst({ + where: and(...whereConditions), + }); + + if (existing) { + skipped.push(vendorId); + continue; + } + + const insertData: { + vendorId: number; + shipbuildingItemId?: number; + offshoreTopItemId?: number; + offshoreHullItemId?: number; + } = { vendorId }; + + if (input.itemType === "SHIP") { + insertData.shipbuildingItemId = input.itemId; + } else if (input.itemType === "TOP") { + insertData.offshoreTopItemId = input.itemId; + } else { + insertData.offshoreHullItemId = input.itemId; + } + + await tx.insert(techVendorPossibleItems).values(insertData); + successCount += 1; + } + }); + + input.vendorIds.forEach((vendorId) => { + revalidateTag(`tech-vendor-possible-items-${vendorId}`); + }); + + return { success: true, successCount, skipped }; + } catch (err) { + console.error("Failed to connect item with vendors:", err); + return { success: false, error: getErrorMessage(err) }; + } +} + /** * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환) * 아이템 코드, 이름, 설명만 간소화해서 반환 diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts index e6138651..61072d3f 100644 --- a/lib/techsales-rfq/repository.ts +++ b/lib/techsales-rfq/repository.ts @@ -94,6 +94,7 @@ export async function selectTechSalesRfqsWithJoin( // 담당자 및 비고
picCode: techSalesRfqs.picCode,
+ hideProjectInfoForVendors: techSalesRfqs.hideProjectInfoForVendors,
remark: techSalesRfqs.remark,
cancelReason: techSalesRfqs.cancelReason,
description: techSalesRfqs.description,
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index cf4d02e2..8ce41cba 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -557,6 +557,7 @@ export async function sendTechSalesRfqToVendors(input: { email?: string | null;
epId?: string | null;
};
+ hideProjectInfoForVendors?: boolean;
}) {
unstable_noStore();
try {
@@ -573,6 +574,7 @@ export async function sendTechSalesRfqToVendors(input: { materialCode: true,
description: true,
rfqType: true,
+ hideProjectInfoForVendors: true,
},
with: {
biddingProject: true,
@@ -604,6 +606,23 @@ export async function sendTechSalesRfqToVendors(input: { }
const isResend = rfq.status === TECH_SALES_RFQ_STATUSES.RFQ_SENT;
+ const effectiveHideProjectInfo =
+ typeof input.hideProjectInfoForVendors === "boolean"
+ ? input.hideProjectInfoForVendors
+ : rfq.hideProjectInfoForVendors ?? false;
+
+ if (
+ typeof input.hideProjectInfoForVendors === "boolean" &&
+ input.hideProjectInfoForVendors !== rfq.hideProjectInfoForVendors
+ ) {
+ await db
+ .update(techSalesRfqs)
+ .set({
+ hideProjectInfoForVendors: input.hideProjectInfoForVendors,
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesRfqs.id, input.rfqId));
+ }
// 현재 사용자 정보 조회
const sender = await db.query.users.findFirst({
@@ -728,6 +747,9 @@ export async function sendTechSalesRfqToVendors(input: { const rfqItemsResult = await getTechSalesRfqItems(rfq.id);
const rfqItems = rfqItemsResult.data || [];
+ const projectNameForVendor = effectiveHideProjectInfo ? "" : rfq.biddingProject?.projNm || "";
+ const projectCodeForVendor = effectiveHideProjectInfo ? "" : rfq.biddingProject?.pspid || "";
+
// 이메일 컨텍스트 구성
const emailContext = {
language: language,
@@ -735,8 +757,8 @@ export async function sendTechSalesRfqToVendors(input: { id: rfq.id,
code: rfq.rfqCode,
title: rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '',
- projectCode: rfq.biddingProject?.pspid || '',
- projectName: rfq.biddingProject?.projNm || '',
+ projectCode: projectCodeForVendor,
+ projectName: projectNameForVendor,
description: rfq.remark || '',
dueDate: rfq.dueDate ? formatDate(rfq.dueDate, "KR") : 'N/A',
materialCode: rfq.materialCode || '',
@@ -990,6 +1012,7 @@ export async function getTechSalesVendorQuotation(quotationId: number) { projMsrm: quotation.projMsrm,
ptypeNm: quotation.ptypeNm,
} : null,
+ hideProjectInfoForVendors: quotation.hideProjectInfoForVendors ?? false,
},
// 벤더 정보
@@ -1414,6 +1437,7 @@ export async function getVendorQuotations(input: { dueDate: techSalesRfqs.dueDate,
rfqStatus: techSalesRfqs.status,
description: techSalesRfqs.description,
+ hideProjectInfoForVendors: techSalesRfqs.hideProjectInfoForVendors,
// 프로젝트 정보 (직접 조인)
projNm: biddingProjects.projNm,
// 아이템 개수
@@ -3662,7 +3686,8 @@ export async function getTechSalesVendorQuotationAttachments(quotationId: number updatedAt: techSalesVendorQuotationAttachments.updatedAt,
})
.from(techSalesVendorQuotationAttachments)
- .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
+ .where(and(eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.isVendorUpload, true)))
.orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
return { data: attachments };
@@ -3680,6 +3705,172 @@ export async function getTechSalesVendorQuotationAttachments(quotationId: number }
/**
+ * 기술영업 RFQ 기준 벤더 견적서 요약 목록 조회 (eml 첨부 전용)
+ */
+export async function getTechSalesVendorQuotationsForRfq(rfqId: number) {
+ unstable_noStore();
+ try {
+ const quotations = await db
+ .select({
+ id: techSalesVendorQuotations.id,
+ vendorId: techSalesVendorQuotations.vendorId,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ quotationVersion: techSalesVendorQuotations.quotationVersion,
+ status: techSalesVendorQuotations.status,
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id))
+ .where(eq(techSalesVendorQuotations.rfqId, rfqId))
+ .orderBy(
+ asc(techVendors.vendorName),
+ asc(techSalesVendorQuotations.id)
+ );
+
+ return { data: quotations, error: null };
+ } catch (error) {
+ console.error("기술영업 RFQ 벤더 견적서 목록 조회 오류:", error);
+ return { data: [], error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 eml 첨부파일 조회 (isVendorUpload = false)
+ */
+export async function getTechSalesVendorQuotationEmlAttachments(quotationId: number) {
+ unstable_noStore();
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(
+ and(
+ eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.isVendorUpload, false)
+ )
+ )
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments, error: null };
+ } catch (error) {
+ console.error("기술영업 벤더 견적서 eml 첨부파일 조회 오류:", error);
+ return { data: [], error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 eml 첨부파일 업로드/삭제 처리
+ * - isVendorUpload = false 로 저장 (메일 등 별도 전달 문서 보관용)
+ */
+export async function processTechSalesVendorQuotationEmlAttachments(params: {
+ quotationId: number;
+ newFiles?: { file: File; description?: string }[];
+ deleteAttachmentIds?: number[];
+ uploadedBy: number;
+ revisionId?: number;
+}) {
+ unstable_noStore();
+ const { quotationId, newFiles = [], deleteAttachmentIds = [], uploadedBy, revisionId } = params;
+
+ try {
+ // 견적서 확인
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ columns: { id: true, rfqId: true, quotationVersion: true },
+ });
+
+ if (!quotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ const targetRevisionId = revisionId ?? quotation.quotationVersion ?? 0;
+
+ await db.transaction(async (tx) => {
+ // 삭제 처리 (벤더 업로드 파일은 삭제하지 않음)
+ if (deleteAttachmentIds.length > 0) {
+ const deletable = await tx.query.techSalesVendorQuotationAttachments.findMany({
+ where: inArray(techSalesVendorQuotationAttachments.id, deleteAttachmentIds),
+ });
+
+ for (const attachment of deletable) {
+ if (attachment.isVendorUpload) {
+ throw new Error("벤더가 업로드한 파일은 여기서 삭제할 수 없습니다.");
+ }
+
+ await tx
+ .delete(techSalesVendorQuotationAttachments)
+ .where(eq(techSalesVendorQuotationAttachments.id, attachment.id));
+
+ try {
+ deleteFile(attachment.filePath);
+ } catch (fileError) {
+ console.warn("eml 첨부파일 삭제 중 파일 시스템 오류:", fileError);
+ }
+ }
+ }
+
+ // 업로드 처리
+ if (newFiles.length > 0) {
+ for (const { file, description } of newFiles) {
+ const saveResult = await saveFile({
+ file,
+ directory: `techsales-quotations/${quotationId}/eml`,
+ userId: uploadedBy.toString(),
+ });
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
+ }
+
+ await tx.insert(techSalesVendorQuotationAttachments).values({
+ quotationId,
+ revisionId: targetRevisionId,
+ fileName: saveResult.fileName!,
+ originalFileName: saveResult.originalName || file.name,
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ filePath: saveResult.publicPath!,
+ description: description || null,
+ uploadedBy,
+ isVendorUpload: false,
+ });
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag(`quotation-${quotationId}`);
+ revalidateTag("quotation-attachments");
+ revalidateTag("techSalesVendorQuotations");
+ if (quotation.rfqId) {
+ revalidateTag(`techSalesRfq-${quotation.rfqId}`);
+ }
+ revalidateTag("techSalesRfqs");
+
+ const refreshed = await getTechSalesVendorQuotationEmlAttachments(quotationId);
+ return { data: refreshed.data, error: refreshed.error };
+ } catch (error) {
+ console.error("기술영업 벤더 견적서 eml 첨부파일 처리 오류:", error);
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+/**
* 특정 리비전의 견적서 첨부파일 조회
*/
export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx index fe9befe5..d3a12385 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx @@ -5,7 +5,7 @@ import type { ColumnDef, Row } from "@tanstack/react-table"; import { formatDate } from "@/lib/utils"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { Checkbox } from "@/components/ui/checkbox";
-import { MessageCircle, MoreHorizontal, Trash2, Paperclip, Users } from "lucide-react";
+import { MessageCircle, MoreHorizontal, Trash2, Paperclip, Users, Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -79,6 +79,7 @@ interface GetColumnsProps<TData> { onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
openContactsDialog?: (quotationId: number, vendorName?: string) => void; // 담당자 조회 다이얼로그 열기
+ openEmlAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // eml 첨부파일 sheet 열기
}
export function getRfqDetailColumns({
@@ -86,7 +87,8 @@ export function getRfqDetailColumns({ unreadMessages = {},
onQuotationClick,
openQuotationAttachmentsSheet,
- openContactsDialog
+ openContactsDialog,
+ openEmlAttachmentsSheet
}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
return [
{
@@ -351,6 +353,42 @@ export function getRfqDetailColumns({ size: 80,
},
{
+ id: "emlAttachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="eml 첨부" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original;
+ const handleClick = () => {
+ if (!openEmlAttachmentsSheet) return;
+ openEmlAttachmentsSheet(quotation.id, {
+ id: quotation.id,
+ quotationCode: quotation.quotationCode || null,
+ vendorName: quotation.vendorName || undefined,
+ rfqCode: quotation.rfqCode || undefined,
+ });
+ };
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label="eml 첨부파일 관리"
+ title="eml 첨부파일 관리"
+ >
+ <Mail className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "eml 첨부"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
id: "contacts",
header: "담당자",
cell: ({ row }) => {
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx index 72f03dc3..d8ced6f8 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -19,6 +19,7 @@ import { VendorCommunicationDrawer } from "./vendor-communication-drawer" import { DeleteVendorDialog } from "./delete-vendors-dialog"
import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
+import { TechSalesVendorEmlAttachmentsSheet, type VendorEmlAttachment } from "../tech-sales-vendor-eml-attachments-sheet"
import type { QuotationInfo } from "./rfq-detail-column"
import { VendorContactSelectionDialog } from "./vendor-contact-selection-dialog"
import { QuotationContactsViewDialog } from "./quotation-contacts-view-dialog"
@@ -89,6 +90,12 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
+ // eml 첨부파일 sheet 상태 관리
+ const [emlAttachmentsSheetOpen, setEmlAttachmentsSheetOpen] = useState(false)
+ const [selectedQuotationForEml, setSelectedQuotationForEml] = useState<QuotationInfo | null>(null)
+ const [emlAttachments, setEmlAttachments] = useState<VendorEmlAttachment[]>([])
+ const [isLoadingEmlAttachments, setIsLoadingEmlAttachments] = useState(false)
+
// 벤더 contact 선택 다이얼로그 상태 관리
const [contactSelectionDialogOpen, setContactSelectionDialogOpen] = useState(false)
@@ -250,7 +257,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps contactId: number;
contactEmail: string;
contactName: string;
- }>) => {
+ }>, options?: { hideProjectInfoForVendors?: boolean }) => {
if (!selectedRfqId) {
toast.error("선택된 RFQ가 없습니다.");
return;
@@ -294,6 +301,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps name: session.data.user.name || undefined,
email: session.data.user.email || undefined,
},
+ hideProjectInfoForVendors: options?.hideProjectInfoForVendors,
});
if (result.success) {
@@ -463,6 +471,31 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps }
}, [])
+ // eml 첨부파일 sheet 열기 핸들러
+ const handleOpenEmlAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
+ try {
+ setIsLoadingEmlAttachments(true)
+ setSelectedQuotationForEml(quotationInfo)
+ setEmlAttachmentsSheetOpen(true)
+
+ const { getTechSalesVendorQuotationEmlAttachments } = await import("@/lib/techsales-rfq/service")
+ const result = await getTechSalesVendorQuotationEmlAttachments(quotationId)
+
+ if (result.error) {
+ toast.error(result.error)
+ setEmlAttachments([])
+ } else {
+ setEmlAttachments(result.data || [])
+ }
+ } catch (error) {
+ console.error("eml 첨부파일 조회 오류:", error)
+ toast.error("eml 첨부파일을 불러오는 중 오류가 발생했습니다.")
+ setEmlAttachments([])
+ } finally {
+ setIsLoadingEmlAttachments(false)
+ }
+ }, [])
+
// 담당자 조회 다이얼로그 열기 함수
const handleOpenContactsDialog = useCallback((quotationId: number, vendorName?: string) => {
setSelectedQuotationForContacts({ id: quotationId, vendorName })
@@ -554,8 +587,9 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps unreadMessages,
onQuotationClick: handleOpenHistoryDialog,
openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet,
- openContactsDialog: handleOpenContactsDialog
- }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog])
+ openContactsDialog: handleOpenContactsDialog,
+ openEmlAttachmentsSheet: handleOpenEmlAttachmentsSheet
+ }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog, handleOpenEmlAttachmentsSheet])
// 필터 필드 정의 (메모이제이션)
const advancedFilterFields = useMemo(
@@ -928,6 +962,16 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps isLoading={isLoadingAttachments}
/>
+ {/* eml 첨부파일 Sheet */}
+ <TechSalesVendorEmlAttachmentsSheet
+ open={emlAttachmentsSheetOpen}
+ onOpenChange={setEmlAttachmentsSheetOpen}
+ quotation={selectedQuotationForEml}
+ attachments={emlAttachments}
+ isLoading={isLoadingEmlAttachments}
+ onAttachmentsChange={setEmlAttachments}
+ />
+
{/* 벤더 contact 선택 다이얼로그 */}
<VendorContactSelectionDialog
open={contactSelectionDialogOpen}
diff --git a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx index d83394bb..8daa9be7 100644 --- a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx @@ -49,7 +49,10 @@ interface VendorContactSelectionDialogProps { onOpenChange: (open: boolean) => void
vendorIds: number[]
rfqId?: number // RFQ ID 추가
- onSendRfq: (selectedContacts: SelectedContact[]) => Promise<void>
+ onSendRfq: (
+ selectedContacts: SelectedContact[],
+ options: { hideProjectInfoForVendors: boolean }
+ ) => Promise<void>
}
export function VendorContactSelectionDialog({
@@ -63,6 +66,7 @@ export function VendorContactSelectionDialog({ const [selectedContacts, setSelectedContacts] = useState<SelectedContact[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSending, setIsSending] = useState(false)
+ const [hideProjectInfoForVendors, setHideProjectInfoForVendors] = useState(false)
// 벤더 contact 정보 조회
useEffect(() => {
@@ -77,6 +81,7 @@ export function VendorContactSelectionDialog({ setVendorsWithContacts({})
setSelectedContacts([])
setIsLoading(false)
+ setHideProjectInfoForVendors(false)
}
}, [open])
@@ -177,7 +182,7 @@ export function VendorContactSelectionDialog({ try {
setIsSending(true)
- await onSendRfq(selectedContacts)
+ await onSendRfq(selectedContacts, { hideProjectInfoForVendors })
onOpenChange(false)
} catch (error) {
console.error("RFQ 발송 오류:", error)
@@ -328,8 +333,17 @@ export function VendorContactSelectionDialog({ <DialogFooter>
<div className="flex items-center justify-between w-full">
- <div className="text-sm text-muted-foreground">
- 총 {selectedContacts.length}명의 연락처가 선택됨
+ <div className="flex flex-col gap-2">
+ <div className="text-sm text-muted-foreground">
+ 총 {selectedContacts.length}명의 연락처가 선택됨
+ </div>
+ <label className="flex items-center gap-2 text-sm">
+ <Checkbox
+ checked={hideProjectInfoForVendors}
+ onCheckedChange={(checked) => setHideProjectInfoForVendors(!!checked)}
+ />
+ 벤더 화면에서 프로젝트명/선주명을 숨기기
+ </label>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
diff --git a/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx new file mode 100644 index 00000000..2b6f6753 --- /dev/null +++ b/lib/techsales-rfq/table/tech-sales-vendor-eml-attachments-sheet.tsx @@ -0,0 +1,348 @@ +"use client" + +import * as React from "react" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list" +import { Badge } from "@/components/ui/badge" +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/components/ui/form" +import { toast } from "sonner" +import { Download, Loader, Trash2, X } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { useSession } from "next-auth/react" +import { useForm } from "react-hook-form" +import { formatDate } from "@/lib/utils" +import { + getTechSalesVendorQuotationEmlAttachments, + processTechSalesVendorQuotationEmlAttachments, +} from "@/lib/techsales-rfq/service" + +const MAX_FILE_SIZE = 6e8 // 600MB + +export interface VendorEmlAttachment { + id: number + quotationId: number + revisionId: number + fileName: string + originalFileName: string + fileSize: number + fileType: string | null + filePath: string + description: string | null + uploadedBy: number | null + vendorId: number | null + isVendorUpload: boolean + createdAt: Date + updatedAt: Date +} + +interface QuotationInfo { + id: number + quotationCode: string | null + vendorName?: string + rfqCode?: string +} + +interface TechSalesVendorEmlAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + quotation: QuotationInfo | null + attachments: VendorEmlAttachment[] + onAttachmentsChange?: (attachments: VendorEmlAttachment[]) => void + isLoading?: boolean +} + +export function TechSalesVendorEmlAttachmentsSheet({ + quotation, + attachments, + onAttachmentsChange, + isLoading = false, + ...props +}: TechSalesVendorEmlAttachmentsSheetProps) { + const session = useSession() + const [isPending, setIsPending] = React.useState(false) + const [existing, setExisting] = React.useState<VendorEmlAttachment[]>(attachments) + const [newUploads, setNewUploads] = React.useState<File[]>([]) + const [deleteIds, setDeleteIds] = React.useState<number[]>([]) + + const form = useForm({ + defaultValues: { + dummy: true, + }, + }) + + // sync when parent changes + React.useEffect(() => { + setExisting(attachments) + setNewUploads([]) + setDeleteIds([]) + }, [attachments]) + + const handleDownloadClick = React.useCallback(async (attachment: VendorEmlAttachment) => { + try { + const { downloadFile } = await import("@/lib/file-download") + await downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName, { + showToast: true, + onError: (error) => { + console.error("다운로드 오류:", error) + toast.error(error) + }, + }) + } catch (error) { + console.error("다운로드 오류:", error) + toast.error("파일 다운로드 중 오류가 발생했습니다.") + } + }, []) + + const handleDropAccepted = React.useCallback((accepted: File[]) => { + setNewUploads((prev) => [...prev, ...accepted]) + }, []) + + const handleDropRejected = React.useCallback(() => { + toast.error("파일 크기가 너무 크거나 지원하지 않는 형식입니다.") + }, []) + + const handleRemoveExisting = React.useCallback((id: number) => { + setDeleteIds((prev) => (prev.includes(id) ? prev : [...prev, id])) + setExisting((prev) => prev.filter((att) => att.id !== id)) + }, []) + + const handleRemoveNewUpload = React.useCallback((index: number) => { + setNewUploads((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const handleSubmit = async () => { + if (!quotation) { + toast.error("견적 정보를 찾을 수 없습니다.") + return + } + + const userId = Number(session.data?.user.id || 0) + if (!userId) { + toast.error("로그인 정보를 확인해주세요.") + return + } + + setIsPending(true) + try { + const result = await processTechSalesVendorQuotationEmlAttachments({ + quotationId: quotation.id, + newFiles: newUploads.map((file) => ({ file })), + deleteAttachmentIds: deleteIds, + uploadedBy: userId, + }) + + if (result.error) { + toast.error(result.error) + return + } + + const refreshed = + result.data || + (await getTechSalesVendorQuotationEmlAttachments(quotation.id)).data || + [] + + setExisting(refreshed) + setNewUploads([]) + setDeleteIds([]) + onAttachmentsChange?.(refreshed) + toast.success("Eml 첨부파일이 저장되었습니다.") + props.onOpenChange?.(false) + } catch (error) { + console.error("eml 첨부파일 저장 오류:", error) + toast.error("eml 첨부파일 저장 중 오류가 발생했습니다.") + } finally { + setIsPending(false) + } + } + + const totalNewSize = newUploads.reduce((acc, f) => acc + f.size, 0) + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>eml 첨부파일</SheetTitle> + <SheetDescription> + <div className="space-y-1"> + {quotation?.vendorName && <div>벤더: {quotation.vendorName}</div>} + {quotation?.rfqCode && <div>RFQ: {quotation.rfqCode}</div>} + </div> + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form onSubmit={(e) => e.preventDefault()} className="flex flex-1 flex-col gap-6"> + {/* 기존 첨부 */} + <div className="grid gap-4"> + <h6 className="font-semibold leading-none tracking-tight"> + 기존 첨부파일 ({existing.length}개) + </h6> + {isLoading ? ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader className="h-4 w-4 animate-spin" /> + 로딩 중... + </div> + ) : existing.length === 0 ? ( + <div className="text-sm text-muted-foreground">첨부파일이 없습니다.</div> + ) : ( + existing.map((att) => ( + <div + key={att.id} + className="flex items-start justify-between p-3 border rounded-md gap-3" + > + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2 mb-1 flex-wrap"> + <p className="text-sm font-medium break-words leading-tight"> + {att.originalFileName || att.fileName} + </p> + <Badge variant="outline" className="text-xs shrink-0"> + rev {att.revisionId} + </Badge> + </div> + <p className="text-xs text-muted-foreground"> + {prettyBytes(att.fileSize)} • {formatDate(att.createdAt, "KR")} + </p> + {att.description && ( + <p className="text-xs text-muted-foreground mt-1 break-words"> + {att.description} + </p> + )} + </div> + + <div className="flex items-center gap-1 shrink-0"> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + type="button" + onClick={() => handleDownloadClick(att)} + title="다운로드" + > + <Download className="h-4 w-4" /> + </Button> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + type="button" + onClick={() => handleRemoveExisting(att.id)} + title="삭제" + > + <Trash2 className="h-4 w-4" /> + </Button> + </div> + </div> + )) + )} + </div> + + {/* 새 업로드 */} + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + > + {({ maxSize }) => ( + <FormField + control={form.control} + name="dummy" + render={() => ( + <FormItem> + <FormLabel>새 eml 파일 업로드</FormLabel> + <DropzoneZone className="flex justify-center"> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle> + <DropzoneDescription> + 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <FormDescription>복수 파일 업로드 가능</FormDescription> + </FormItem> + )} + /> + )} + </Dropzone> + + {newUploads.length > 0 && ( + <div className="grid gap-3"> + <div className="flex items-center justify-between"> + <h6 className="font-semibold leading-none tracking-tight"> + 새 파일 ({newUploads.length}개) + </h6> + <span className="text-xs text-muted-foreground"> + 총 용량 {prettyBytes(totalNewSize)} + </span> + </div> + <FileList> + {newUploads.map((file, idx) => ( + <FileListItem key={`${file.name}-${idx}`}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription>{prettyBytes(file.size)}</FileListDescription> + </FileListInfo> + <FileListAction onClick={() => handleRemoveNewUpload(idx)}> + <X /> + <span className="sr-only">제거</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </div> + )} + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 닫기 + </Button> + </SheetClose> + <Button + type="button" + onClick={handleSubmit} + disabled={isPending || (!newUploads.length && deleteIds.length === 0)} + > + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + {isPending ? "저장 중..." : "저장"} + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +} + diff --git a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx index 8a45f529..31e87330 100644 --- a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx @@ -16,6 +16,7 @@ interface ProjectInfoTabProps { dueDate: Date | null
status: string | null
remark: string | null
+ hideProjectInfoForVendors?: boolean
biddingProject?: {
id: number
pspid: string | null
@@ -110,7 +111,9 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) { <CardHeader>
<CardTitle className="flex items-center gap-2">
프로젝트 기본 정보
- <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge>
+ <Badge variant="outline">
+ {rfq.hideProjectInfoForVendors ? "비공개" : (rfq.biddingProject.pspid || "N/A")}
+ </Badge>
</CardTitle>
<CardDescription>
연결된 프로젝트의 기본 정보
@@ -120,11 +123,15 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) { <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
- <div className="text-sm">{rfq.biddingProject.pspid || "N/A"}</div>
+ <div className="text-sm">
+ {rfq.hideProjectInfoForVendors ? "비공개" : (rfq.biddingProject.pspid || "N/A")}
+ </div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
- <div className="text-sm">{rfq.biddingProject.projNm || "N/A"}</div>
+ <div className="text-sm">
+ {rfq.hideProjectInfoForVendors ? "비공개" : (rfq.biddingProject.projNm || "N/A")}
+ </div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">프로젝트 섹터</div>
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx index aabe7a64..97f21be2 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx @@ -27,6 +27,7 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { materialCode?: string;
dueDate?: Date;
rfqStatus?: string;
+ hideProjectInfoForVendors?: boolean;
// 아이템 정보
itemName?: string;
@@ -258,17 +259,19 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog, open ),
cell: ({ row }) => {
const projNm = row.getValue("projNm") as string;
+ const hideProjectInfo = row.original.hideProjectInfoForVendors === true;
+ const displayValue = hideProjectInfo ? "비공개" : projNm || "N/A";
return (
<div className="min-w-48 max-w-64">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate block text-sm">
- {projNm || "N/A"}
+ {displayValue}
</span>
</TooltipTrigger>
<TooltipContent>
- <p className="max-w-xs">{projNm || "N/A"}</p>
+ <p className="max-w-xs">{displayValue}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index d4e0ff33..b6cf6d7a 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -44,7 +44,8 @@ import { updateDocument, deleteDocuments, updateStage, - getDocumentClassOptionsByContract + getDocumentClassOptionsByContract, + checkDuplicateDocuments } from "./document-stages-service" import { type Row } from "@tanstack/react-table" @@ -127,6 +128,14 @@ export function AddDocumentDialog({ const [cpyTypeConfigs, setCpyTypeConfigs] = React.useState<any[]>([]) const [cpyComboBoxOptions, setCpyComboBoxOptions] = React.useState<Record<number, any[]>>({}) + // Duplicate check states + const [duplicateWarning, setDuplicateWarning] = React.useState<{ + isDuplicate: boolean + type?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH' + message?: string + }>({ isDuplicate: false }) + const [isCheckingDuplicate, setIsCheckingDuplicate] = React.useState(false) + // Initialize react-hook-form const form = useForm<DocumentFormValues>({ resolver: zodResolver(documentFormSchema), @@ -167,6 +176,7 @@ export function AddDocumentDialog({ setShiComboBoxOptions({}) setCpyComboBoxOptions({}) setDocumentClassOptions([]) + setDuplicateWarning({ isDuplicate: false }) } }, [open]) @@ -359,6 +369,59 @@ export function AddDocumentDialog({ return preview && preview !== '' && !preview.includes('[value]') } + // Real-time duplicate check with debounce + const checkDuplicateDebounced = React.useMemo(() => { + let timeoutId: NodeJS.Timeout | null = null + + return (shiDocNo: string, cpyDocNo: string) => { + if (timeoutId) { + clearTimeout(timeoutId) + } + + timeoutId = setTimeout(async () => { + // Skip if both are empty or incomplete + if ((!shiDocNo || shiDocNo.includes('[value]')) && + (!cpyDocNo || cpyDocNo.includes('[value]'))) { + setDuplicateWarning({ isDuplicate: false }) + return + } + + setIsCheckingDuplicate(true) + try { + const result = await checkDuplicateDocuments( + contractId, + shiDocNo && !shiDocNo.includes('[value]') ? shiDocNo : undefined, + cpyDocNo && !cpyDocNo.includes('[value]') ? cpyDocNo : undefined + ) + + if (result.isDuplicate) { + setDuplicateWarning({ + isDuplicate: true, + type: result.duplicateType, + message: result.message + }) + } else { + setDuplicateWarning({ isDuplicate: false }) + } + } catch (error) { + console.error('Duplicate check error:', error) + } finally { + setIsCheckingDuplicate(false) + } + }, 500) // 500ms debounce + } + }, [contractId]) + + // Trigger duplicate check when document numbers change + React.useEffect(() => { + const shiPreview = generateShiPreview() + const cpyPreview = generateCpyPreview() + + if (shiPreview || cpyPreview) { + checkDuplicateDebounced(shiPreview, cpyPreview) + } + }, [shiFieldValues, cpyFieldValues]) + const onSubmit = async (data: DocumentFormValues) => { // Validate that at least one document number is configured and complete if (shiType && !isShiComplete()) { @@ -520,6 +583,24 @@ export function AddDocumentDialog({ <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col min-h-0"> <div className="flex-1 overflow-y-auto pr-2 space-y-4"> + {/* Duplicate Warning Alert */} + {duplicateWarning.isDuplicate && ( + <Alert variant="destructive" className="border-red-300 bg-red-50 dark:bg-red-950/50"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription className="font-medium"> + {duplicateWarning.message} + </AlertDescription> + </Alert> + )} + + {/* Checking Duplicate Indicator */} + {isCheckingDuplicate && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + Checking for duplicates... + </div> + )} + {/* SHI Document Number Card */} {shiType && ( <Card className="border-blue-200 dark:border-blue-800"> @@ -719,7 +800,9 @@ export function AddDocumentDialog({ form.formState.isSubmitting || !hasAvailableTypes || (shiType && !isShiComplete()) || - (cpyType && !isCpyComplete()) + (cpyType && !isCpyComplete()) || + duplicateWarning.isDuplicate || + isCheckingDuplicate } > {form.formState.isSubmitting ? ( diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index ed4099b3..cf19eb41 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -878,6 +878,127 @@ interface CreateDocumentData { vendorDocNumber?: string } +// ═══════════════════════════════════════════════════════════════════════════════ +// 문서번호 중복 체크 함수 (SHI_DOC_NO / OWN_DOC_NO 각각 중복 방지) +// ═══════════════════════════════════════════════════════════════════════════════ +interface CheckDuplicateResult { + isDuplicate: boolean + duplicateType?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH' + existingDocNumbers?: { + shiDocNo?: string + ownDocNo?: string + } + message?: string +} + +/** + * 프로젝트 내에서 SHI_DOC_NO (docNumber)와 OWN_DOC_NO (vendorDocNumber) 중복 체크 + * @param contractId 계약 ID (프로젝트 ID를 가져오기 위함) + * @param shiDocNo SHI 문서번호 (docNumber) + * @param ownDocNo CPY 문서번호 (vendorDocNumber) + * @param excludeDocumentId 수정 시 제외할 문서 ID (선택) + */ +export async function checkDuplicateDocuments( + contractId: number, + shiDocNo?: string, + ownDocNo?: string, + excludeDocumentId?: number +): Promise<CheckDuplicateResult> { + try { + // 1. 계약에서 프로젝트 ID 가져오기 + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + columns: { projectId: true }, + }) + + if (!contract) { + return { isDuplicate: false, message: "유효하지 않은 계약입니다." } + } + + const { projectId } = contract + let shiDuplicate = false + let ownDuplicate = false + const existingDocNumbers: { shiDocNo?: string; ownDocNo?: string } = {} + + // 2. SHI_DOC_NO 중복 체크 (docNumber) + if (shiDocNo && shiDocNo.trim() !== '') { + const shiConditions = [ + eq(stageDocuments.projectId, projectId), + eq(stageDocuments.docNumber, shiDocNo.trim()), + eq(stageDocuments.status, "ACTIVE"), + ] + + if (excludeDocumentId) { + shiConditions.push(ne(stageDocuments.id, excludeDocumentId)) + } + + const existingShiDoc = await db + .select({ id: stageDocuments.id, docNumber: stageDocuments.docNumber }) + .from(stageDocuments) + .where(and(...shiConditions)) + .limit(1) + + if (existingShiDoc.length > 0) { + shiDuplicate = true + existingDocNumbers.shiDocNo = existingShiDoc[0].docNumber + } + } + + // 3. OWN_DOC_NO 중복 체크 (vendorDocNumber) + if (ownDocNo && ownDocNo.trim() !== '') { + const ownConditions = [ + eq(stageDocuments.projectId, projectId), + eq(stageDocuments.vendorDocNumber, ownDocNo.trim()), + eq(stageDocuments.status, "ACTIVE"), + ] + + if (excludeDocumentId) { + ownConditions.push(ne(stageDocuments.id, excludeDocumentId)) + } + + const existingOwnDoc = await db + .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber }) + .from(stageDocuments) + .where(and(...ownConditions)) + .limit(1) + + if (existingOwnDoc.length > 0) { + ownDuplicate = true + existingDocNumbers.ownDocNo = existingOwnDoc[0].vendorDocNumber || undefined + } + } + + // 4. 결과 반환 + if (shiDuplicate && ownDuplicate) { + return { + isDuplicate: true, + duplicateType: 'BOTH', + existingDocNumbers, + message: `SHI Document Number '${shiDocNo}' and CPY Document Number '${ownDocNo}' already exist in this project.` + } + } else if (shiDuplicate) { + return { + isDuplicate: true, + duplicateType: 'SHI_DOC_NO', + existingDocNumbers, + message: `SHI Document Number '${shiDocNo}' already exists in this project.` + } + } else if (ownDuplicate) { + return { + isDuplicate: true, + duplicateType: 'OWN_DOC_NO', + existingDocNumbers, + message: `CPY Document Number '${ownDocNo}' already exists in this project.` + } + } + + return { isDuplicate: false } + } catch (error) { + console.error("중복 체크 실패:", error) + return { isDuplicate: false, message: "중복 체크 중 오류가 발생했습니다." } + } +} + // 문서 생성 export async function createDocument(data: CreateDocumentData) { try { @@ -907,6 +1028,20 @@ export async function createDocument(data: CreateDocumentData) { return { success: false, error: configsResult.error } } + /* ──────────────────────────────── 2. 중복 체크 (SHI_DOC_NO & OWN_DOC_NO) ─────────────────────────────── */ + const duplicateCheck = await checkDuplicateDocuments( + data.contractId, + data.docNumber, + data.vendorDocNumber + ) + + if (duplicateCheck.isDuplicate) { + return { + success: false, + error: duplicateCheck.message || "Document number already exists in this project.", + duplicateType: duplicateCheck.duplicateType, + } + } /* ──────────────────────────────── 3. 문서 레코드 삽입 ─────────────────────────────── */ const insertData = { @@ -1403,7 +1538,7 @@ export async function uploadImportData(data: UploadData) { try { // 개별 트랜잭션으로 각 문서 처리 const result = await db.transaction(async (tx) => { - // 먼저 문서가 이미 존재하는지 확인 + // 먼저 SHI_DOC_NO (docNumber)가 이미 존재하는지 확인 const [existingDoc] = await tx .select({ id: stageDocuments.id }) .from(stageDocuments) @@ -1417,7 +1552,26 @@ export async function uploadImportData(data: UploadData) { .limit(1) if (existingDoc) { - throw new Error(`문서번호 "${doc.docNumber}"가 이미 존재합니다`) + throw new Error(`SHI Document Number "${doc.docNumber}" already exists in this project`) + } + + // OWN_DOC_NO (vendorDocNumber) 중복 체크 + if (doc.vendorDocNumber && doc.vendorDocNumber.trim() !== '') { + const [existingVendorDoc] = await tx + .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber }) + .from(stageDocuments) + .where( + and( + eq(stageDocuments.projectId, contract.projectId), + eq(stageDocuments.vendorDocNumber, doc.vendorDocNumber.trim()), + eq(stageDocuments.status, "ACTIVE") + ) + ) + .limit(1) + + if (existingVendorDoc) { + throw new Error(`CPY Document Number "${doc.vendorDocNumber}" already exists in this project`) + } } // 3-1. 문서 생성 diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts index cf37ad06..5e53d0dd 100644 --- a/lib/vendor-investigation/service.ts +++ b/lib/vendor-investigation/service.ts @@ -339,6 +339,9 @@ export async function updateVendorInvestigationResultAction(formData: FormData) processedEntries.investigationNotes = textEntries.investigationNotes } + // attachments는 별도로 업로드되므로 빈 배열로 설정 + processedEntries.attachments = [] + // 3) Zod로 파싱/검증 const parsed = updateVendorInvestigationResultSchema.parse(processedEntries) diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts index 84361ef9..29fb46cb 100644 --- a/lib/vendor-investigation/validations.ts +++ b/lib/vendor-investigation/validations.ts @@ -140,7 +140,8 @@ export const updateVendorInvestigationResultSchema = z.object({ .max(100, "평가 점수는 100점 이하여야 합니다."), evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]), investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(), - attachments: z.array(z.any()).min(1, "최소 1개의 첨부파일이 필요합니다."), + // attachments는 별도의 API로 업로드되므로 이 스키마에서는 optional + attachments: z.array(z.any()).optional(), }).superRefine((data, ctx) => { // 날짜 검증: 실제 실사일이 실사의뢰일보다 과거가 되지 않도록 검증 if (data.requestedAt && data.completedAt) { @@ -198,7 +199,7 @@ export const updateVendorInvestigationSchema = z.object({ .optional(), evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]).optional(), investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(), - attachments: z.any().optional(), // File 업로드를 위한 필드 + attachments: z.array(z.any()).min(1, "최소 1개의 첨부파일이 필요합니다."), // File 업로드 필수 }).superRefine((data, ctx) => { // 날짜 검증: 실사의뢰일(requestedAt)이 있는 경우 다른 날짜들이 실사의뢰일보다 과거가 되지 않도록 검증 if (data.requestedAt) { diff --git a/lib/vendors/items-table/item-action-dialog.tsx b/lib/vendors/items-table/item-action-dialog.tsx index 19df27f8..6bbcc436 100644 --- a/lib/vendors/items-table/item-action-dialog.tsx +++ b/lib/vendors/items-table/item-action-dialog.tsx @@ -1,248 +1,289 @@ -// components/vendor-items/item-actions-dialogs.tsx "use client" import * as React from "react" -import type { DataTableRowAction } from "@/types/table" -import { VendorItemsView } from "@/db/schema/vendors" -import { toast } from "sonner" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Check, ChevronsUpDown } from "lucide-react" +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" -import { Button } from "@/components/ui/button" -import { Label } from "@/components/ui/label" + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { cn } from "@/lib/utils" -import { updateVendorItem, deleteVendorItem, getItemsForVendor } from "../service" +import { + createVendorItemSchema, + type CreateVendorItemSchema, +} from "../validations" -interface ItemActionsDialogsProps { +import { createVendorItem, getItemsForVendor, ItemDropdownOption } from "../service" + +interface AddItemDialogProps { vendorId: number - rowAction: DataTableRowAction<VendorItemsView> | null - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorItemsView> | null>> } -export function ItemActionsDialogs({ - vendorId, - rowAction, - setRowAction, -}: ItemActionsDialogsProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const [isDeletePending, startDeleteTransition] = React.useTransition() - const [availableMaterials, setAvailableMaterials] = React.useState<any[]>([]) - const [selectedItemCode, setSelectedItemCode] = React.useState<string>("") - - // 사용 가능한 재료 목록 로드 - React.useEffect(() => { - if (rowAction?.type === "update") { - getItemsForVendor(vendorId).then((result) => { - if (result.data) { - setAvailableMaterials(result.data) - } - }) - } - }, [rowAction, vendorId]) - - // Edit Dialog - const EditDialog = () => { - if (!rowAction || rowAction.type !== "update") return null +export function AddItemDialog({ vendorId }: AddItemDialogProps) { + const [open, setOpen] = React.useState(false) + const [commandOpen, setCommandOpen] = React.useState(false) + const [items, setItems] = React.useState<ItemDropdownOption[]>([]) + const [filteredItems, setFilteredItems] = React.useState<ItemDropdownOption[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + + // 선택된 아이템의 정보를 보여주기 위한 상태 + const [selectedItem, setSelectedItem] = React.useState<{ + itemName: string; + description: string; + } | null>(null) - const item = rowAction.row.original + // react-hook-form 세팅 - 서버로 보낼 값은 vendorId와 itemCode만 + const form = useForm<CreateVendorItemSchema>({ + resolver: zodResolver(createVendorItemSchema), + defaultValues: { + vendorId, + itemCode: "", + }, + }) - const handleSubmit = () => { - if (!selectedItemCode) { - toast.error("Please select a new item") - return - } + console.log(vendorId) - if (!item.itemCode) { - toast.error("Invalid item code") - return + // 아이템 목록 가져오기 (한 번만 호출) + const fetchItems = React.useCallback(async () => { + if (items.length > 0) return // 이미 로드된 경우 스킵 + + setIsLoading(true) + try { + const result = await getItemsForVendor(vendorId) + if (result.data) { + setItems(result.data) + setFilteredItems(result.data) } + } catch (error) { + console.error("Failed to fetch items:", error) + } finally { + setIsLoading(false) + } + }, [items.length]) - startUpdateTransition(async () => { - const result = await updateVendorItem(vendorId, item.itemCode, selectedItemCode) - - if (result.error) { - toast.error(result.error) - } else { - toast.success("Item updated successfully") - setRowAction(null) - } - }) + // 팝오버 열릴 때 아이템 목록 로드 + React.useEffect(() => { + if (commandOpen) { + fetchItems() } + }, [commandOpen, fetchItems]) - return ( - <Dialog - open={true} - onOpenChange={(open) => !open && setRowAction(null)} - > - <DialogContent className="sm:max-w-[425px]"> - <DialogHeader> - <DialogTitle>Change Item</DialogTitle> - <DialogDescription> - Select a new item to replace "{item.itemName}" (Code: {item.itemCode || 'N/A'}). - </DialogDescription> - </DialogHeader> - - <div className="space-y-4"> - <div className="space-y-2"> - <Label>Current Item</Label> - <div className="p-2 bg-muted rounded-md"> - <div className="font-medium">{item.itemName}</div> - <div className="text-sm text-muted-foreground">Code: {item.itemCode || 'N/A'}</div> - </div> - </div> - - <div className="space-y-2"> - <Label htmlFor="newItem">New Item</Label> - <Select value={selectedItemCode} onValueChange={setSelectedItemCode}> - <SelectTrigger> - <SelectValue placeholder="Select a new item" /> - </SelectTrigger> - <SelectContent> - {availableMaterials.map((material) => ( - <SelectItem key={material.itemCode} value={material.itemCode}> - <div> - <div className="font-medium">{material.itemName}</div> - <div className="text-sm text-muted-foreground">Code: {material.itemCode}</div> - </div> - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => setRowAction(null)} - disabled={isUpdatePending} - > - Cancel - </Button> - <Button - onClick={handleSubmit} - disabled={isUpdatePending || !selectedItemCode} - > - {isUpdatePending ? "Updating..." : "Update Item"} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> + // 클라이언트 사이드 필터링 + React.useEffect(() => { + if (!items.length) return + + if (!searchTerm.trim()) { + setFilteredItems(items) + return + } + + const lowerSearch = searchTerm.toLowerCase() + const filtered = items.filter(item => + item.itemCode.toLowerCase().includes(lowerSearch) || + item.itemName.toLowerCase().includes(lowerSearch) || + (item.description && item.description.toLowerCase().includes(lowerSearch)) ) - } - - // Delete Dialog - const DeleteDialog = () => { - if (!rowAction || rowAction.type !== "delete") return null - - const item = rowAction.row.original + + setFilteredItems(filtered) + }, [searchTerm, items]) - const handleDelete = () => { - if (!item.itemCode) { - toast.error("Invalid item code") - return - } + // 선택된 아이템 데이터로 폼 업데이트 + const handleSelectItem = (item: ItemDropdownOption) => { + // 폼에는 itemCode만 설정 + form.setValue("itemCode", item.itemCode) + + // 나머지 정보는 표시용 상태에 저장 + setSelectedItem({ + itemName: item.itemName, + description: item.description || "", + }) + + setCommandOpen(false) + } - startDeleteTransition(async () => { - const result = await deleteVendorItem(vendorId, item.itemCode) - - if (result.error) { - toast.error(result.error) - } else { - toast.success("Item deleted successfully") - setRowAction(null) - } - }) + // 폼 제출 - itemCode만 서버로 전송 + async function onSubmit(data: CreateVendorItemSchema) { + // 서버에는 vendorId와 itemCode만 전송됨 + const result = await createVendorItem(data) + console.log(result) + if (result.error) { + alert(`에러: ${result.error}`) + return } - - return ( - <AlertDialog - open={true} - onOpenChange={(open) => !open && setRowAction(null)} - > - <AlertDialogContent> - return ( - <AlertDialog - open={true} - onOpenChange={(open) => !open && setRowAction(null)} - > - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>Are you sure?</AlertDialogTitle> - <AlertDialogDescription> - This will permanently delete the item "{item.itemName}" (Code: {item.itemCode || 'N/A'}). - This action cannot be undone. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel disabled={isDeletePending}> - Cancel - </AlertDialogCancel> - <AlertDialogAction - onClick={handleDelete} - disabled={isDeletePending} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {isDeletePending ? "Deleting..." : "Delete"} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - ) + // 성공 시 모달 닫고 폼 리셋 + form.reset() + setSelectedItem(null) + setOpen(false) } - return ( - <> - <EditDialog /> - <DeleteDialog /> - </> - ) -} - <AlertDialogFooter> - <AlertDialogCancel disabled={isDeletePending}> - Cancel - </AlertDialogCancel> - <AlertDialogAction - onClick={handleDelete} - disabled={isDeletePending} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {isDeletePending ? "Deleting..." : "Delete"} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - ) + // 모달 열림/닫힘 핸들 + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + // 닫힐 때 폼 리셋 + form.reset() + setSelectedItem(null) + } + setOpen(nextOpen) } + // 현재 선택된 아이템 코드 + const selectedItemCode = form.watch("itemCode") + + // 선택된 아이템 코드가 있으면 상세 정보 표시를 위한 아이템 찾기 + const displayItemCode = selectedItemCode || "아이템 선택..." + const displayItemName = selectedItem?.itemName || "" + return ( - <> - <EditDialog /> - <DeleteDialog /> - </> + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달 열기 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Item + </Button> + </DialogTrigger> + + <DialogContent className="max-h-[90vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle>Create New Item</DialogTitle> + <DialogDescription> + 아이템을 선택한 후 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form + react-hook-form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden"> + <div className="space-y-4 py-4 flex-1 overflow-y-auto"> + + {/* 아이템 선택 */} + <div> + <FormLabel className="text-sm font-medium">아이템 선택</FormLabel> + <Popover open={commandOpen} onOpenChange={setCommandOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={commandOpen} + className="w-full justify-between mt-1" + > + {selectedItemCode + ? `${selectedItemCode} - ${displayItemName}` + : "아이템 선택..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="아이템 코드/이름 검색..." + onValueChange={setSearchTerm} + /> + <CommandList className="max-h-[200px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + {isLoading ? ( + <div className="py-6 text-center text-sm">로딩 중...</div> + ) : ( + <CommandGroup> + {filteredItems.map((item) => ( + <CommandItem + key={item.itemCode} + value={`${item.itemCode} ${item.itemName}`} + onSelect={() => handleSelectItem(item)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedItemCode === item.itemCode + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">{item.itemCode}</span> + <span className="ml-2 text-gray-500 truncate">- {item.itemName}</span> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + + {/* 아이템 정보 영역 - 선택된 경우에만 표시 */} + {selectedItem && ( + <div className="rounded-md border p-3 mt-4 overflow-hidden"> + <h3 className="font-medium text-sm mb-2">선택된 아이템 정보</h3> + + {/* Item Code - readonly (hidden field) */} + <FormField + control={form.control} + name="itemCode" + render={({ field }) => ( + <FormItem className="hidden"> + <FormControl> + <Input {...field} /> + </FormControl> + </FormItem> + )} + /> + + {/* Item Name (표시용) */} + <div className="mb-2"> + <p className="text-xs font-medium text-gray-500">Item Name</p> + <p className="text-sm mt-0.5 break-words">{selectedItem.itemName}</p> + </div> + + {/* Description (표시용) */} + {selectedItem.description && ( + <div> + <p className="text-xs font-medium text-gray-500">Description</p> + <p className="text-sm mt-0.5 break-words max-h-20 overflow-y-auto">{selectedItem.description}</p> + </div> + )} + </div> + )} + + </div> + + <DialogFooter className="flex-shrink-0 pt-2"> + <Button type="button" variant="outline" onClick={() => setOpen(false)}> + Cancel + </Button> + <Button + type="submit" + disabled={form.formState.isSubmitting || !selectedItemCode} + > + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> ) }
\ No newline at end of file |
