summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-24 20:16:56 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-24 20:16:56 +0900
commit6bc4162b19f06ad4f919270ebcd4ef18f31cd490 (patch)
treebe37a152174789d269ef718c2a1f3794531e1c37 /lib
parent775997501ef36bf07d7f1f2e1d4abe7c97505e96 (diff)
parenta8674e6b91fb4d356c311fad0251878de154da53 (diff)
(김준회) 최겸프로 작업사항 병합
Diffstat (limited to 'lib')
-rw-r--r--lib/approval/handlers-registry.ts10
-rw-r--r--lib/approval/templates/입찰 결과 업체 선정 품의 요청서.html788
-rw-r--r--lib/approval/templates/폐찰 품의 요청서.html581
-rw-r--r--lib/bidding/approval-actions.ts325
-rw-r--r--lib/bidding/detail/bidding-actions.ts160
-rw-r--r--lib/bidding/detail/service.ts58
-rw-r--r--lib/bidding/detail/table/bidding-award-dialog.tsx190
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx77
-rw-r--r--lib/bidding/failure/biddings-closure-dialog.tsx77
-rw-r--r--lib/bidding/failure/biddings-failure-table.tsx81
-rw-r--r--lib/bidding/handlers.ts429
-rw-r--r--lib/bidding/list/bidding-pr-documents-dialog.tsx2
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx64
-rw-r--r--lib/bidding/service.ts69
-rw-r--r--lib/bidding/validation.ts2
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx8
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx27
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx13
-rw-r--r--lib/rfq-last/quotation-compare-view.tsx2
-rw-r--r--lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx23
-rw-r--r--lib/rfq-last/vendor/price-adjustment-dialog.tsx23
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx10
-rw-r--r--lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx710
-rw-r--r--lib/vendors/bid-history-table/bid-history-table-columns.tsx2
-rw-r--r--lib/vendors/bid-history-table/bid-history-table.tsx2
25 files changed, 2835 insertions, 898 deletions
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts
index 5c173565..beb6b971 100644
--- a/lib/approval/handlers-registry.ts
+++ b/lib/approval/handlers-registry.ts
@@ -68,6 +68,16 @@ export async function initializeApprovalHandlers() {
// 입찰초대 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingInvitationInternal)
registerActionHandler('bidding_invitation', requestBiddingInvitationInternal);
+ // 9. 폐찰 핸들러
+ const { requestBiddingClosureInternal } = await import('@/lib/bidding/handlers');
+ // 폐찰 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingClosureInternal)
+ registerActionHandler('bidding_closure', requestBiddingClosureInternal);
+
+ // 10. 낙찰 핸들러
+ const { requestBiddingAwardInternal } = await import('@/lib/bidding/handlers');
+ // 낙찰 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingAwardInternal)
+ registerActionHandler('bidding_award', requestBiddingAwardInternal);
+
// ... 추가 핸들러 등록
console.log('[Approval Handlers] All handlers registered successfully');
diff --git a/lib/approval/templates/입찰 결과 업체 선정 품의 요청서.html b/lib/approval/templates/입찰 결과 업체 선정 품의 요청서.html
new file mode 100644
index 00000000..50e1ff54
--- /dev/null
+++ b/lib/approval/templates/입찰 결과 업체 선정 품의 요청서.html
@@ -0,0 +1,788 @@
+<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;
+ "
+ >
+ *결재 완료 후 낙찰이 반영되며, 협력사로 통보됩니다.
+ </td>
+ </tr>
+ </thead>
+ </table>
+
+
+
+ <!-- 2. 입찰 기본 정보 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 15px;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="4"
+ 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: 35%;
+ 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: 35%;
+ 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>
+ </tr>
+ <!-- 3행 -->
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ P/R번호
+ </td>
+ <td
+ style="
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{P/R번호}}
+ </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>
+ </tr>
+ <!-- 5행: 입찰 개요 -->
+ <tr>
+ <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;
+ 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="11"
+ 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>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ width: 15%;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 선정 사유
+ </td>
+ <td
+ colspan="10"
+ style="
+ padding: 8px 10px;
+ height: 50px;
+ border: 1px solid #ccc;
+ vertical-align: top;
+ "
+ >
+ {{업체선정사유}}
+ </td>
+ </tr>
+ <!-- 업체 선정 결과 테이블 헤더 -->
+ <tr>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 5%;
+ "
+ >
+ 순번
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 10%;
+ "
+ >
+ 협력사 코드
+ </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: 8%;
+ "
+ >
+ 기업규모
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 8%;
+ "
+ >
+ 연동제 희망
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 8%;
+ "
+ >
+ 연동제 적용
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 8%;
+ "
+ >
+ 낙찰 유무
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 12%;
+ "
+ >
+ 확정 금액
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 8%;
+ "
+ >
+ 내정액
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 8%;
+ "
+ >
+ 입찰액
+ </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;">1</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_1}}</td>
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_1}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{기업규모_1}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{연동제희망여부_1}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{연동제적용여부_1}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{낙찰유무_1}}</td>
+ <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{확정금액_1}}</td>
+ <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{내정액_1}}</td>
+ <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{입찰액_1}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{비율_1}}</td>
+ </tr>
+ <tr>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">2</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_2}}</td>
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_2}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{기업규모_2}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{연동제희망여부_2}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{연동제적용여부_2}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{낙찰유무_2}}</td>
+ <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{확정금액_2}}</td>
+ <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{내정액_2}}</td>
+ <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{입찰액_2}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{비율_2}}</td>
+ </tr>
+ <!-- /업체 선정 결과 데이터 행 -->
+ </tbody>
+ </table>
+
+
+
+ <!-- 4. 자재별 입찰 정보 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 15px;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="13"
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 10px;
+ text-align: left;
+ font-size: 15px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 품목별 입찰 정보 (총 {{대상_자재_수}} 건 - 결재본문 내 표시 품목은 10건 이하로 제한됩니다)
+ </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: 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: 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: 15%;
+ "
+ >
+ 낙찰 협력사명
+ </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; 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: 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; border: 1px solid #ccc;">{{협력사명_1}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{입찰액_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; 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: 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; border: 1px solid #ccc;">{{협력사명_2}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{입찰액_2}}</td>
+ </tr>
+ <!-- /자재별 입찰 정보 데이터 행 -->
+ </tbody>
+ </table>
+
+
+
+ <!-- 5. 연동제 NOTE (템플릿만 - TODO) -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 15px;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="3"
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 10px;
+ text-align: left;
+ font-size: 15px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 연동제 NOTE
+ </th>
+ </tr>
+ <tr>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 33%;
+ "
+ >
+ 업체명
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 33%;
+ "
+ >
+ 연동 합의서
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 34%;
+ "
+ >
+ 미연동 합의서
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <!-- 연동제 NOTE 데이터 행 (반복 영역 - 변수 매핑 불필요 - TODO) -->
+ <tr>
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{업체명_연동제_1}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">[첨부파일 링크 또는 Y/N]</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">[첨부파일 링크 또는 Y/N]</td>
+ </tr>
+ <tr>
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{업체명_연동제_2}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">[첨부파일 링크 또는 Y/N]</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">[첨부파일 링크 또는 Y/N]</td>
+ </tr>
+ <!-- /연동제 NOTE 데이터 행 -->
+ </tbody>
+ </table>
+
+</div>
diff --git a/lib/approval/templates/폐찰 품의 요청서.html b/lib/approval/templates/폐찰 품의 요청서.html
new file mode 100644
index 00000000..dafda83c
--- /dev/null
+++ b/lib/approval/templates/폐찰 품의 요청서.html
@@ -0,0 +1,581 @@
+<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;
+ "
+ >
+ *결재 완료 후 폐찰 처리됩니다.
+ </td>
+ </tr>
+ </thead>
+ </table>
+
+
+
+ <!-- 2. 입찰 기본 정보 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 15px;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="4"
+ 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: 35%;
+ 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: 35%;
+ 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>
+ </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>
+ </tr>
+ <!-- 4행: 입찰 개요 -->
+ <tr>
+ <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;
+ 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="7"
+ 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>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ width: 15%;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 폐찰 사유
+ </td>
+ <td
+ colspan="6"
+ style="
+ padding: 8px 10px;
+ height: 50px;
+ border: 1px solid #ccc;
+ vertical-align: top;
+ "
+ >
+ {{폐찰_사유}}
+ </td>
+ </tr>
+ <!-- 폐찰 결과 테이블 헤더 -->
+ <tr>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 5%;
+ "
+ >
+ 순번
+ </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: 25%;
+ "
+ >
+ 협력사명
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 10%;
+ "
+ >
+ 응찰 유무
+ </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: 15%;
+ "
+ >
+ 입찰가/내정가(%)
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <!-- 폐찰 결과 데이터 행 (반복 영역) -->
+ <tr>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">1</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_1}}</td>
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_1}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{응찰유무_1}}</td>
+ <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{내정가_1}}</td>
+ <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{입찰가_1}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{비율_1}}</td>
+ </tr>
+ <tr>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">2</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_2}}</td>
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_2}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{응찰유무_2}}</td>
+ <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{내정가_2}}</td>
+ <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{입찰가_2}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{비율_2}}</td>
+ </tr>
+ <!-- /폐찰 결과 데이터 행 -->
+ </tbody>
+ </table>
+
+
+
+ <!-- 4. 품목별 입찰 정보 -->
+ <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: 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: 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: 15%;
+ "
+ >
+ 내정가
+ </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: 15%;
+ "
+ >
+ 입찰가
+ </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; 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: 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; border: 1px solid #ccc;">{{협력사명_1}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{입찰가_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; 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: 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; border: 1px solid #ccc;">{{협력사명_2}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{입찰가_2}}</td>
+ </tr>
+ <!-- /품목별 입찰 정보 데이터 행 -->
+ </tbody>
+ </table>
+
+</div>
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts
index 3a82b08f..6f02e80c 100644
--- a/lib/bidding/approval-actions.ts
+++ b/lib/bidding/approval-actions.ts
@@ -12,7 +12,7 @@
'use server';
import { ApprovalSubmissionSaga } from '@/lib/approval';
-import { mapBiddingInvitationToTemplateVariables } from './handlers';
+import { mapBiddingInvitationToTemplateVariables, mapBiddingClosureToTemplateVariables, mapBiddingAwardToTemplateVariables } from './handlers';
import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
/**
@@ -99,7 +99,7 @@ export async function prepareBiddingApprovalData(data: {
materialCode: prItemsForBidding.materialNumber,
materialCodeName: prItemsForBidding.materialInfo,
quantity: prItemsForBidding.quantity,
- purchasingUnit: prItemsForBidding.purchaseUnit,
+ purchasingUnit: prItemsForBidding.priceUnit,
targetUnitPrice: prItemsForBidding.targetUnitPrice,
quantityUnit: prItemsForBidding.quantityUnit,
totalWeight: prItemsForBidding.totalWeight,
@@ -241,3 +241,324 @@ export async function requestBiddingInvitationWithApproval(data: {
return result;
}
+
+/**
+ * 폐찰 결재를 거쳐 입찰 폐찰을 처리하는 서버 액션
+ *
+ * ✅ 사용법 (클라이언트 컴포넌트에서):
+ * ```typescript
+ * const result = await requestBiddingClosureWithApproval({
+ * biddingId: 123,
+ * description: "폐찰 사유",
+ * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' },
+ * approvers: ['EP002', 'EP003']
+ * });
+ *
+ * if (result.status === 'pending_approval') {
+ * toast.success(`폐찰 결재가 상신되었습니다. (ID: ${result.approvalId})`);
+ * }
+ * ```
+ */
+/**
+ * 폐찰 결재를 위한 공통 데이터 준비 헬퍼 함수
+ */
+export async function prepareBiddingClosureApprovalData(data: {
+ biddingId: number;
+ description: string;
+}) {
+ // 1. 입찰 정보 조회 (템플릿 변수용)
+ debugLog('[BiddingClosureApproval] 입찰 정보 조회 시작');
+ const { default: db } = await import('@/db/db');
+ const { biddings } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ const biddingInfo = await db
+ .select({
+ id: biddings.id,
+ title: biddings.title,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, data.biddingId))
+ .limit(1);
+
+ if (biddingInfo.length === 0) {
+ debugError('[BiddingClosureApproval] 입찰 정보를 찾을 수 없음');
+ throw new Error('입찰 정보를 찾을 수 없습니다');
+ }
+
+ debugLog('[BiddingClosureApproval] 입찰 정보 조회 완료', {
+ biddingId: data.biddingId,
+ title: biddingInfo[0].title,
+ });
+
+ // 2. 템플릿 변수 매핑
+ debugLog('[BiddingClosureApproval] 템플릿 변수 매핑 시작');
+ const requestedAt = new Date();
+ const { mapBiddingClosureToTemplateVariables } = await import('./handlers');
+ const variables = await mapBiddingClosureToTemplateVariables({
+ biddingId: data.biddingId,
+ description: data.description,
+ requestedAt,
+ });
+ debugLog('[BiddingClosureApproval] 템플릿 변수 매핑 완료', {
+ variableKeys: Object.keys(variables),
+ });
+
+ return {
+ bidding: biddingInfo[0],
+ variables,
+ };
+}
+
+export async function requestBiddingClosureWithApproval(data: {
+ biddingId: number;
+ description: string;
+ files?: File[];
+ currentUser: { id: number; epId: string | null; email?: string };
+ approvers?: string[]; // Knox EP ID 배열 (결재선)
+}) {
+ debugLog('[BiddingClosureApproval] 폐찰 결재 서버 액션 시작', {
+ biddingId: data.biddingId,
+ description: data.description,
+ userId: data.currentUser.id,
+ hasEpId: !!data.currentUser.epId,
+ });
+
+ // 1. 입력 검증
+ if (!data.currentUser.epId) {
+ debugError('[BiddingClosureApproval] Knox EP ID 없음');
+ throw new Error('Knox EP ID가 필요합니다');
+ }
+
+ if (!data.description.trim()) {
+ debugError('[BiddingClosureApproval] 폐찰 사유 없음');
+ throw new Error('폐찰 사유를 입력해주세요');
+ }
+ // 유찰상태인지 확인
+ const { bidding } = await db
+ .select()
+ .from(biddings)
+ .where(eq(biddings.id, data.biddingId))
+ .limit(1);
+
+ if (bidding.status !== 'bidding_disposal') {
+ debugError('[BiddingClosureApproval] 유찰 상태가 아닙니다.');
+ throw new Error('유찰 상태인 입찰만 폐찰할 수 있습니다.');
+ }
+
+ // 2. 입찰 상태를 결재 진행중으로 변경
+ debugLog('[BiddingClosureApproval] 입찰 상태 변경 시작');
+ const { default: db } = await import('@/db/db');
+ const { biddings } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ await db
+ .update(biddings)
+ .set({
+ status: 'closure_pending', // 폐찰 결재 진행중 상태
+ updatedBy: data.currentUser.epId,
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, data.biddingId));
+
+ debugLog('[BiddingClosureApproval] 입찰 상태 변경 완료', {
+ biddingId: data.biddingId,
+ newStatus: 'closure_pending'
+ });
+
+ // 3. 결재 데이터 준비
+ const { bidding: approvalBidding, variables } = await prepareBiddingClosureApprovalData({
+ biddingId: data.biddingId,
+ description: data.description,
+ });
+
+ // 4. 결재 워크플로우 시작 (Saga 패턴)
+ debugLog('[BiddingClosureApproval] ApprovalSubmissionSaga 생성');
+ const saga = new ApprovalSubmissionSaga(
+ // actionType: 핸들러를 찾을 때 사용할 키
+ 'bidding_closure',
+
+ // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만)
+ {
+ biddingId: data.biddingId,
+ description: data.description,
+ files: data.files,
+ currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요
+ },
+
+ // approvalConfig: 결재 상신 정보 (템플릿 포함)
+ {
+ title: `폐찰 - ${approvalBidding.title}`,
+ description: `${approvalBidding.title} 입찰 폐찰 결재`,
+ templateName: '폐찰 품의 요청서', // 한국어 템플릿명
+ variables, // 치환할 변수들
+ approvers: data.approvers,
+ currentUser: data.currentUser,
+ }
+ );
+
+ debugLog('[BiddingClosureApproval] Saga 실행 시작');
+ const result = await saga.execute();
+
+ debugSuccess('[BiddingClosureApproval] 폐찰 결재 워크플로우 완료', {
+ approvalId: result.approvalId,
+ pendingActionId: result.pendingActionId,
+ status: result.status,
+ });
+
+ return result;
+}
+
+/**
+ * 낙찰 결재를 거쳐 입찰 낙찰을 처리하는 서버 액션
+ *
+ * ✅ 사용법 (클라이언트 컴포넌트에서):
+ * ```typescript
+ * const result = await requestBiddingAwardWithApproval({
+ * biddingId: 123,
+ * selectionReason: "낙찰 사유",
+ * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' },
+ * approvers: ['EP002', 'EP003']
+ * });
+ *
+ * if (result.status === 'pending_approval') {
+ * toast.success(`낙찰 결재가 상신되었습니다. (ID: ${result.approvalId})`);
+ * }
+ * ```
+ */
+/**
+ * 낙찰 결재를 위한 공통 데이터 준비 헬퍼 함수
+ */
+export async function prepareBiddingAwardApprovalData(data: {
+ biddingId: number;
+ selectionReason: string;
+}) {
+ // 1. 입찰 정보 조회 (템플릿 변수용)
+ debugLog('[BiddingAwardApproval] 입찰 정보 조회 시작');
+ const { default: db } = await import('@/db/db');
+ const { biddings } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ const biddingInfo = await db
+ .select({
+ id: biddings.id,
+ title: biddings.title,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, data.biddingId))
+ .limit(1);
+
+ if (biddingInfo.length === 0) {
+ debugError('[BiddingAwardApproval] 입찰 정보를 찾을 수 없음');
+ throw new Error('입찰 정보를 찾을 수 없습니다');
+ }
+
+ debugLog('[BiddingAwardApproval] 입찰 정보 조회 완료', {
+ biddingId: data.biddingId,
+ title: biddingInfo[0].title,
+ });
+
+ // 2. 템플릿 변수 매핑
+ debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 시작');
+ const requestedAt = new Date();
+ const { mapBiddingAwardToTemplateVariables } = await import('./handlers');
+ const variables = await mapBiddingAwardToTemplateVariables({
+ biddingId: data.biddingId,
+ selectionReason: data.selectionReason,
+ requestedAt,
+ });
+ debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 완료', {
+ variableKeys: Object.keys(variables),
+ });
+
+ return {
+ bidding: biddingInfo[0],
+ variables,
+ };
+}
+
+export async function requestBiddingAwardWithApproval(data: {
+ biddingId: number;
+ selectionReason: string;
+ currentUser: { id: number; epId: string | null; email?: string };
+ approvers?: string[]; // Knox EP ID 배열 (결재선)
+}) {
+ debugLog('[BiddingAwardApproval] 낙찰 결재 서버 액션 시작', {
+ biddingId: data.biddingId,
+ selectionReason: data.selectionReason,
+ userId: data.currentUser.id,
+ hasEpId: !!data.currentUser.epId,
+ });
+
+ // 1. 입력 검증
+ if (!data.currentUser.epId) {
+ debugError('[BiddingAwardApproval] Knox EP ID 없음');
+ throw new Error('Knox EP ID가 필요합니다');
+ }
+
+ if (!data.selectionReason.trim()) {
+ debugError('[BiddingAwardApproval] 낙찰 사유 없음');
+ throw new Error('낙찰 사유를 입력해주세요');
+ }
+
+ // 2. 입찰 상태를 결재 진행중으로 변경
+ debugLog('[BiddingAwardApproval] 입찰 상태 변경 시작');
+ const { default: db } = await import('@/db/db');
+ const { biddings } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ await db
+ .update(biddings)
+ .set({
+ status: 'award_pending', // 낙찰 결재 진행중 상태
+ updatedBy: data.currentUser.epId,
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, data.biddingId));
+
+ debugLog('[BiddingAwardApproval] 입찰 상태 변경 완료', {
+ biddingId: data.biddingId,
+ newStatus: 'award_pending'
+ });
+
+ // 3. 결재 데이터 준비
+ const { bidding, variables } = await prepareBiddingAwardApprovalData({
+ biddingId: data.biddingId,
+ selectionReason: data.selectionReason,
+ });
+
+ // 4. 결재 워크플로우 시작 (Saga 패턴)
+ debugLog('[BiddingAwardApproval] ApprovalSubmissionSaga 생성');
+ const saga = new ApprovalSubmissionSaga(
+ // actionType: 핸들러를 찾을 때 사용할 키
+ 'bidding_award',
+
+ // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만)
+ {
+ biddingId: data.biddingId,
+ selectionReason: data.selectionReason,
+ currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요
+ },
+
+ // approvalConfig: 결재 상신 정보 (템플릿 포함)
+ {
+ title: `낙찰 - ${bidding.title}`,
+ description: `${bidding.title} 입찰 낙찰 결재`,
+ templateName: '입찰 결과 업체 선정 품의 요청서', // 한국어 템플릿명
+ variables, // 치환할 변수들
+ approvers: data.approvers,
+ currentUser: data.currentUser,
+ }
+ );
+
+ debugLog('[BiddingAwardApproval] Saga 실행 시작');
+ const result = await saga.execute();
+
+ debugSuccess('[BiddingAwardApproval] 낙찰 결재 워크플로우 완료', {
+ approvalId: result.approvalId,
+ pendingActionId: result.pendingActionId,
+ status: result.status,
+ });
+
+ return result;
+}
diff --git a/lib/bidding/detail/bidding-actions.ts b/lib/bidding/detail/bidding-actions.ts
index 70bba1c3..fb659039 100644
--- a/lib/bidding/detail/bidding-actions.ts
+++ b/lib/bidding/detail/bidding-actions.ts
@@ -143,85 +143,85 @@ export async function checkAllVendorsFinalSubmitted(biddingId: number) {
}
}
-// 개찰 서버 액션 (조기개찰/개찰 구분)
-export async function performBidOpening(
- biddingId: number,
- userId: string,
- isEarly: boolean = false // 조기개찰 여부
-) {
- try {
- const userName = await getUserNameById(userId)
+// // 개찰 서버 액션 (조기개찰/개찰 구분)
+// export async function performBidOpening(
+// biddingId: number,
+// userId: string,
+// isEarly: boolean = false // 조기개찰 여부
+// ) {
+// try {
+// const userName = await getUserNameById(userId)
- return await db.transaction(async (tx) => {
- // 1. 입찰 정보 조회
- const [bidding] = await tx
- .select({
- id: biddings.id,
- status: biddings.status,
- submissionEndDate: biddings.submissionEndDate,
- })
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1)
-
- if (!bidding) {
- return {
- success: false,
- error: '입찰 정보를 찾을 수 없습니다.'
- }
- }
-
- // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만)
- if (bidding.status !== 'evaluation_of_bidding') {
- return {
- success: false,
- error: '입찰평가중 상태에서만 개찰할 수 있습니다.'
- }
- }
-
- // 3. 모든 벤더가 최종제출했는지 확인
- const checkResult = await checkAllVendorsFinalSubmitted(biddingId)
- if (!checkResult.allSubmitted) {
- return {
- success: false,
- error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})`
- }
- }
-
- // 4. 조기개찰 여부 결정
- const now = new Date()
- const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null
- const isBeforeDeadline = submissionEndDate && now < submissionEndDate
-
- // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰
- const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening'
-
- // 5. 입찰 상태 변경
- await tx
- .update(biddings)
- .set({
- status: newStatus,
- updatedAt: new Date()
- })
- .where(eq(biddings.id, biddingId))
-
- // 캐시 무효화
- revalidateTag(`bidding-${biddingId}`)
- revalidateTag('bidding-detail')
- revalidatePath(`/evcp/bid/${biddingId}`)
-
- return {
- success: true,
- message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`,
- status: newStatus
- }
- })
- } catch (error) {
- console.error('Failed to perform bid opening:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '개찰에 실패했습니다.'
- }
- }
-}
+// return await db.transaction(async (tx) => {
+// // 1. 입찰 정보 조회
+// const [bidding] = await tx
+// .select({
+// id: biddings.id,
+// status: biddings.status,
+// submissionEndDate: biddings.submissionEndDate,
+// })
+// .from(biddings)
+// .where(eq(biddings.id, biddingId))
+// .limit(1)
+
+// if (!bidding) {
+// return {
+// success: false,
+// error: '입찰 정보를 찾을 수 없습니다.'
+// }
+// }
+
+// // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만)
+// if (bidding.status !== 'evaluation_of_bidding') {
+// return {
+// success: false,
+// error: '입찰평가중 상태에서만 개찰할 수 있습니다.'
+// }
+// }
+
+// // 3. 모든 벤더가 최종제출했는지 확인
+// const checkResult = await checkAllVendorsFinalSubmitted(biddingId)
+// if (!checkResult.allSubmitted) {
+// return {
+// success: false,
+// error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})`
+// }
+// }
+
+// // 4. 조기개찰 여부 결정
+// const now = new Date()
+// const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null
+// const isBeforeDeadline = submissionEndDate && now < submissionEndDate
+
+// // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰
+// const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening'
+
+// // 5. 입찰 상태 변경
+// await tx
+// .update(biddings)
+// .set({
+// status: newStatus,
+// updatedAt: new Date()
+// })
+// .where(eq(biddings.id, biddingId))
+
+// // 캐시 무효화
+// revalidateTag(`bidding-${biddingId}`)
+// revalidateTag('bidding-detail')
+// revalidatePath(`/evcp/bid/${biddingId}`)
+
+// return {
+// success: true,
+// message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`,
+// status: newStatus
+// }
+// })
+// } catch (error) {
+// console.error('Failed to perform bid opening:', error)
+// return {
+// success: false,
+// error: error instanceof Error ? error.message : '개찰에 실패했습니다.'
+// }
+// }
+// }
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index d0f8070f..297c6f98 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -1251,9 +1251,55 @@ export async function getAwardedCompanies(biddingId: number) {
}
}
+// 입찰의 PR 아이템 금액 합산하여 bidding 업데이트
+async function updateBiddingAmounts(biddingId: number) {
+ try {
+ // 해당 bidding의 모든 PR 아이템들의 금액 합계 계산
+ const amounts = await db
+ .select({
+ totalTargetAmount: sql<number>`COALESCE(SUM(${prItemsForBidding.targetAmount}), 0)`,
+ totalBudgetAmount: sql<number>`COALESCE(SUM(${prItemsForBidding.budgetAmount}), 0)`,
+ totalActualAmount: sql<number>`COALESCE(SUM(${prItemsForBidding.actualAmount}), 0)`
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ const { totalTargetAmount, totalBudgetAmount, totalActualAmount } = amounts[0]
+
+ // bidding 테이블 업데이트
+ await db
+ .update(biddings)
+ .set({
+ targetPrice: totalTargetAmount,
+ budget: totalBudgetAmount,
+ finalBidPrice: totalActualAmount,
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, biddingId))
+
+ console.log(`Bidding ${biddingId} amounts updated: target=${totalTargetAmount}, budget=${totalBudgetAmount}, actual=${totalActualAmount}`)
+ } catch (error) {
+ console.error('Failed to update bidding amounts:', error)
+ throw error
+ }
+}
+
// PR 품목 정보 업데이트
export async function updatePrItem(prItemId: number, input: Partial<typeof prItemsForBidding.$inferSelect>, userId: string) {
try {
+ // 업데이트 전 biddingId 확인
+ const prItem = await db
+ .select({ biddingId: prItemsForBidding.biddingId })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.id, prItemId))
+ .limit(1)
+
+ if (!prItem[0]?.biddingId) {
+ throw new Error('PR item not found or biddingId is missing')
+ }
+
+ const biddingId = prItem[0].biddingId
+
await db
.update(prItemsForBidding)
.set({
@@ -1262,12 +1308,14 @@ export async function updatePrItem(prItemId: number, input: Partial<typeof prIte
})
.where(eq(prItemsForBidding.id, prItemId))
+ // PR 아이템 금액 합산하여 bidding 업데이트
+ await updateBiddingAmounts(biddingId)
+
// 캐시 무효화
- if (input.biddingId) {
- revalidateTag(`bidding-${input.biddingId}`)
- revalidateTag('pr-items')
- revalidatePath(`/evcp/bid/${input.biddingId}`)
- }
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('pr-items')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+
return { success: true, message: '품목 정보가 성공적으로 업데이트되었습니다.' }
} catch (error) {
console.error('Failed to update PR item:', error)
diff --git a/lib/bidding/detail/table/bidding-award-dialog.tsx b/lib/bidding/detail/table/bidding-award-dialog.tsx
index 9a4614bd..ff104fac 100644
--- a/lib/bidding/detail/table/bidding-award-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-award-dialog.tsx
@@ -26,7 +26,8 @@ import {
} from '@/components/ui/table'
import { Trophy, Building2, Calculator } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
-import { getAwardedCompanies, awardBidding } from '@/lib/bidding/detail/service'
+import { getAwardedCompanies } from '@/lib/bidding/detail/service'
+import { requestBiddingAwardWithApproval } from '@/lib/bidding/approval-actions'
import { AwardSimpleFileUpload } from './components/award-simple-file-upload'
interface BiddingAwardDialogProps {
@@ -34,6 +35,12 @@ interface BiddingAwardDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
+ onApprovalPreview?: (data: {
+ templateName: string
+ variables: Record<string, string>
+ title: string
+ selectionReason: string
+ }) => void
}
interface AwardedCompany {
@@ -47,7 +54,8 @@ export function BiddingAwardDialog({
biddingId,
open,
onOpenChange,
- onSuccess
+ onSuccess,
+ onApprovalPreview
}: BiddingAwardDialogProps) {
const { toast } = useToast()
const { data: session } = useSession()
@@ -106,26 +114,36 @@ const userId = session?.user?.id || '2';
return
}
- startTransition(async () => {
- const result = await awardBidding(biddingId, selectionReason, userId)
+ // 결재 템플릿 변수 준비
+ const { mapBiddingAwardToTemplateVariables } = await import('@/lib/bidding/handlers')
- if (result.success) {
- toast({
- title: '성공',
- description: result.message,
- })
- onSuccess()
- onOpenChange(false)
- // 폼 초기화
- setSelectionReason('')
- } else {
- toast({
- title: '오류',
- description: result.error,
- variant: 'destructive',
+ try {
+ const variables = await mapBiddingAwardToTemplateVariables({
+ biddingId,
+ selectionReason,
+ requestedAt: new Date()
+ })
+
+ // 상위 컴포넌트로 결재 미리보기 데이터 전달
+ if (onApprovalPreview) {
+ onApprovalPreview({
+ templateName: '입찰 결과 업체 선정 품의 요청서',
+ variables,
+ title: `낙찰 - ${bidding?.title}`,
+ selectionReason
})
}
- })
+
+ onOpenChange(false)
+ setSelectionReason('')
+ } catch (error) {
+ console.error('낙찰 템플릿 변수 준비 실패:', error)
+ toast({
+ title: '오류',
+ description: '결재 문서 준비 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
}
@@ -251,11 +269,143 @@ const userId = session?.user?.id || '2';
type="submit"
disabled={isPending || awardedCompanies.length === 0}
>
- {isPending ? '처리 중...' : '낙찰 완료'}
+ {isPending ? '상신 중...' : '결재 상신'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
+
+ return (
+ <>
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Trophy className="w-5 h-5 text-yellow-600" />
+ 낙찰 처리
+ </DialogTitle>
+ <DialogDescription>
+ 낙찰된 업체의 발주비율과 선정 사유를 확인하고 낙찰을 완료하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit}>
+ <div className="space-y-6">
+ {/* 낙찰 업체 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Building2 className="w-4 h-4" />
+ 낙찰 업체 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ {isLoading ? (
+ <div className="text-center py-4">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
+ <p className="mt-2 text-sm text-muted-foreground">낙찰 업체 정보를 불러오는 중...</p>
+ </div>
+ ) : awardedCompanies.length > 0 ? (
+ <div className="space-y-4">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>업체명</TableHead>
+ <TableHead className="text-right">견적금액</TableHead>
+ <TableHead className="text-right">발주비율</TableHead>
+ <TableHead className="text-right">발주금액</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {awardedCompanies.map((company) => (
+ <TableRow key={company.companyId}>
+ <TableCell className="font-medium">
+ <div className="flex items-center gap-2">
+ <Badge variant="default" className="bg-green-600">낙찰</Badge>
+ {company.companyName}
+ </div>
+ </TableCell>
+ <TableCell className="text-right">
+ {company.finalQuoteAmount.toLocaleString()}원
+ </TableCell>
+ <TableCell className="text-right">
+ {company.awardRatio}%
+ </TableCell>
+ <TableCell className="text-right font-semibold">
+ {(company.finalQuoteAmount * company.awardRatio / 100).toLocaleString()}원
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+
+ {/* 최종입찰가 요약 */}
+ <div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg">
+ <div className="flex items-center gap-2">
+ <Calculator className="w-5 h-5 text-blue-600" />
+ <span className="font-semibold text-blue-800">최종입찰가</span>
+ </div>
+ <span className="text-xl font-bold text-blue-800">
+ {finalBidPrice.toLocaleString()}원
+ </span>
+ </div>
+ </div>
+ ) : (
+ <div className="text-center py-8">
+ <Trophy className="w-12 h-12 text-gray-400 mx-auto mb-4" />
+ <p className="text-gray-500 mb-2">낙찰된 업체가 없습니다</p>
+ <p className="text-sm text-gray-400">
+ 먼저 업체 수정 다이얼로그에서 발주비율을 산정해주세요.
+ </p>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 낙찰 사유 */}
+ <div className="space-y-2">
+ <Label htmlFor="selectionReason">
+ 낙찰 사유 <span className="text-red-500">*</span>
+ </Label>
+ <Textarea
+ id="selectionReason"
+ placeholder="낙찰 사유를 상세히 입력해주세요..."
+ value={selectionReason}
+ onChange={(e) => setSelectionReason(e.target.value)}
+ rows={4}
+ className="resize-none"
+ />
+ </div>
+
+ {/* 첨부파일 */}
+ <AwardSimpleFileUpload
+ biddingId={biddingId}
+ userId={userId}
+ readOnly={false}
+ />
+ </div>
+
+ <DialogFooter className="mt-6">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isPending || awardedCompanies.length === 0}
+ >
+ {isPending ? '상신 중...' : '결재 상신'}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index 1fa116ab..08fc0293 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -14,6 +14,8 @@ import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib
import { Bidding } from '@/db/schema'
import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
import { QuotationHistoryDialog } from './quotation-history-dialog'
+import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
+import { requestBiddingAwardWithApproval } from '@/lib/bidding/approval-actions'
import { useToast } from '@/hooks/use-toast'
interface BiddingDetailVendorTableContentProps {
@@ -99,6 +101,13 @@ export function BiddingDetailVendorTableContent({
const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null)
const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false)
+ const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
+ templateName: string
+ variables: Record<string, string>
+ title: string
+ selectionReason: string
+ } | null>(null)
+ const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false)
const handleEdit = (vendor: QuotationVendor) => {
setSelectedVendor(vendor)
@@ -187,6 +196,47 @@ export function BiddingDetailVendorTableContent({
clearOnDefault: true,
})
+ // 낙찰 결재 상신 핸들러
+ const handleAwardApprovalConfirm = async (data: { approvers: string[]; title: string; attachments?: File[] }) => {
+ if (!session?.user?.id || !approvalPreviewData) return
+
+ try {
+ const result = await requestBiddingAwardWithApproval({
+ biddingId,
+ selectionReason: approvalPreviewData.selectionReason,
+ currentUser: {
+ id: Number(session.user.id),
+ epId: session.user.epId || null,
+ email: session.user.email || undefined
+ },
+ approvers: data.approvers,
+ })
+
+ if (result.status === 'pending_approval') {
+ toast({
+ title: '성공',
+ description: `낙찰 결재가 상신되었습니다. (ID: ${result.approvalId})`,
+ })
+ setIsApprovalPreviewDialogOpen(false)
+ setApprovalPreviewData(null)
+ onRefresh()
+ } else {
+ toast({
+ title: '오류',
+ description: '낙찰 결재 상신 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('낙찰 결재 상신 실패:', error)
+ toast({
+ title: '오류',
+ description: '낙찰 결재 상신 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
+
return (
<>
<DataTable table={table}>
@@ -221,6 +271,10 @@ export function BiddingDetailVendorTableContent({
open={isAwardDialogOpen}
onOpenChange={setIsAwardDialogOpen}
onSuccess={onRefresh}
+ onApprovalPreview={(data) => {
+ setApprovalPreviewData(data)
+ setIsApprovalPreviewDialogOpen(true)
+ }}
/>
<PriceAdjustmentDialog
@@ -238,6 +292,29 @@ export function BiddingDetailVendorTableContent({
biddingCurrency={quotationHistoryData?.biddingCurrency || 'KRW'}
targetPrice={quotationHistoryData?.targetPrice}
/>
+
+ {/* 낙찰 결재 미리보기 다이얼로그 */}
+ {session?.user && session.user.epId && approvalPreviewData && (
+ <ApprovalPreviewDialog
+ open={isApprovalPreviewDialogOpen}
+ onOpenChange={(open) => {
+ setIsApprovalPreviewDialogOpen(open)
+ if (!open) {
+ setApprovalPreviewData(null)
+ }
+ }}
+ templateName={approvalPreviewData.templateName}
+ variables={approvalPreviewData.variables}
+ title={approvalPreviewData.title}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined
+ }}
+ onConfirm={handleAwardApprovalConfirm}
+ />
+ )}
</>
)
}
diff --git a/lib/bidding/failure/biddings-closure-dialog.tsx b/lib/bidding/failure/biddings-closure-dialog.tsx
index 64aba42f..93ba0eda 100644
--- a/lib/bidding/failure/biddings-closure-dialog.tsx
+++ b/lib/bidding/failure/biddings-closure-dialog.tsx
@@ -2,8 +2,9 @@
"use client"
import { useState } from "react"
+import { useSession } from "next-auth/react"
import { toast } from "sonner"
-import { bidClosureAction } from "@/lib/bidding/actions"
+import { requestBiddingClosureWithApproval } from "@/lib/bidding/approval-actions"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
@@ -19,17 +20,29 @@ interface BiddingsClosureDialogProps {
title: string;
biddingNumber: string;
} | null;
- userId: string;
onSuccess?: () => void;
+ onApprovalPreview?: (data: {
+ templateName: string
+ variables: Record<string, string>
+ title: string
+ description: string
+ files?: File[]
+ }) => void
}
+
+interface ClosureFormData {
+ description: string;
+ files: File[];
+}
export function BiddingsClosureDialog({
open,
onOpenChange,
bidding,
- userId,
- onSuccess
+ onSuccess,
+ onApprovalPreview
}: BiddingsClosureDialogProps) {
+ const { data: session } = useSession()
const [description, setDescription] = useState('')
const [files, setFiles] = useState<File[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -42,36 +55,44 @@ interface BiddingsClosureDialogProps {
return
}
- setIsSubmitting(true)
-
+ // 결재 템플릿 변수 준비
+ const { mapBiddingClosureToTemplateVariables } = await import('@/lib/bidding/handlers')
+
try {
- const result = await bidClosureAction(bidding.id, {
+ const variables = await mapBiddingClosureToTemplateVariables({
+ biddingId: bidding.id,
description: description.trim(),
- files
- }, userId)
-
- if (result.success) {
- toast.success(result.message)
- onOpenChange(false)
- onSuccess?.()
- // 페이지 새로고침 또는 상태 업데이트
- window.location.reload()
- } else {
- toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.')
+ requestedAt: new Date()
+ })
+
+ // 상위 컴포넌트로 결재 미리보기 데이터 전달
+ if (onApprovalPreview) {
+ onApprovalPreview({
+ templateName: '폐찰 품의 요청서',
+ variables,
+ title: `폐찰 - ${bidding.title}`,
+ description: description.trim(),
+ files
+ })
}
+
+ onOpenChange(false)
+ // 폼 초기화
+ setDescription('')
+ setFiles([])
} catch (error) {
- toast.error('폐찰 처리 중 오류가 발생했습니다.')
- } finally {
- setIsSubmitting(false)
+ console.error('폐찰 템플릿 변수 준비 실패:', error)
+ toast.error('결재 문서 준비 중 오류가 발생했습니다.')
}
}
-
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files))
}
}
-
+
+
if (!bidding) return null
return (
@@ -99,7 +120,7 @@ interface BiddingsClosureDialogProps {
required
/>
</div>
-
+
<div className="space-y-2">
<Label htmlFor="files">첨부파일</Label>
<Input
@@ -116,7 +137,7 @@ interface BiddingsClosureDialogProps {
</div>
)}
</div>
-
+
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
@@ -131,12 +152,12 @@ interface BiddingsClosureDialogProps {
variant="destructive"
disabled={isSubmitting || !description.trim()}
>
- {isSubmitting ? '처리 중...' : '폐찰하기'}
+ {isSubmitting ? '상신 중...' : '결재 상신'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
- )
- }
+ </>
+ )
\ No newline at end of file
diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx
index 43020322..a0f98466 100644
--- a/lib/bidding/failure/biddings-failure-table.tsx
+++ b/lib/bidding/failure/biddings-failure-table.tsx
@@ -24,6 +24,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { FileX, RefreshCw, Undo2 } from "lucide-react"
import { bidClosureAction, cancelDisposalAction } from "@/lib/bidding/actions"
import { increaseRoundOrRebid } from "@/lib/bidding/service"
+import { ApprovalPreviewDialog } from "@/lib/approval/approval-preview-dialog"
+import { requestBiddingClosureWithApproval } from "@/lib/bidding/approval-actions"
import { useToast } from "@/hooks/use-toast"
type BiddingFailureItem = {
@@ -88,6 +90,14 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
const [selectedBidding, setSelectedBidding] = React.useState<BiddingFailureItem | null>(null)
const [isRebidDialogOpen, setIsRebidDialogOpen] = React.useState(false)
const [selectedBiddingForRebid, setSelectedBiddingForRebid] = React.useState<BiddingFailureItem | null>(null)
+ const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
+ templateName: string
+ variables: Record<string, string>
+ title: string
+ description: string
+ files?: File[]
+ } | null>(null)
+ const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false)
const { toast } = useToast()
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingFailureItem> | null>(null)
@@ -424,11 +434,14 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
open={biddingClosureDialogOpen}
onOpenChange={handleBiddingClosureDialogClose}
bidding={selectedBidding}
- userId={session.user.id}
onSuccess={() => {
router.refresh()
handleBiddingClosureDialogClose()
}}
+ onApprovalPreview={(data) => {
+ setApprovalPreviewData(data)
+ setIsApprovalPreviewDialogOpen(true)
+ }}
/>
)}
@@ -465,6 +478,72 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
</DialogFooter>
</DialogContent>
</Dialog>
+
+ {/* 폐찰 결재 미리보기 다이얼로그 */}
+ {session?.user && session.user.epId && approvalPreviewData && (
+ <ApprovalPreviewDialog
+ open={isApprovalPreviewDialogOpen}
+ onOpenChange={(open) => {
+ setIsApprovalPreviewDialogOpen(open)
+ if (!open) {
+ setApprovalPreviewData(null)
+ }
+ }}
+ templateName={approvalPreviewData.templateName}
+ variables={approvalPreviewData.variables}
+ title={approvalPreviewData.title}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined
+ }}
+ onConfirm={handleClosureApprovalConfirm}
+ />
+ )}
</>
)
+
+ // 폐찰 결재 상신 핸들러
+ const handleClosureApprovalConfirm = async (data: { approvers: string[]; title: string; attachments?: File[] }) => {
+ if (!session?.user?.id || !approvalPreviewData || !selectedBidding) return
+
+ try {
+ const result = await requestBiddingClosureWithApproval({
+ biddingId: selectedBidding.id,
+ description: approvalPreviewData.description,
+ files: approvalPreviewData.files,
+ currentUser: {
+ id: Number(session.user.id),
+ epId: session.user.epId || null,
+ email: session.user.email || undefined
+ },
+ approvers: data.approvers,
+ })
+
+ if (result.status === 'pending_approval') {
+ toast({
+ title: '성공',
+ description: `폐찰 결재가 상신되었습니다. (ID: ${result.approvalId})`,
+ })
+ setIsApprovalPreviewDialogOpen(false)
+ setApprovalPreviewData(null)
+ handleBiddingClosureDialogClose()
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: '폐찰 결재 상신 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('폐찰 결재 상신 실패:', error)
+ toast({
+ title: '오류',
+ description: '폐찰 결재 상신 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
}
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts
index fc2951d4..d55107c0 100644
--- a/lib/bidding/handlers.ts
+++ b/lib/bidding/handlers.ts
@@ -281,3 +281,432 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
...materialVariables,
};
}
+
+/**
+ * 폐찰 데이터를 결재 템플릿 변수로 매핑
+ *
+ * @param payload - 폐찰 데이터
+ * @returns 템플릿 변수 객체 (Record<string, string>)
+ */
+export async function mapBiddingClosureToTemplateVariables(payload: {
+ biddingId: number;
+ description: string;
+ requestedAt: Date;
+}): Promise<Record<string, string>> {
+ const { biddingId, description, requestedAt } = payload;
+
+ // 1. 입찰 정보 조회
+ debugLog('[BiddingClosureMapper] 입찰 정보 조회 시작');
+ const { default: db } = await import('@/db/db');
+ const { biddings, prItemsForBidding, biddingCompanies, biddingVendorSubmissions } = await import('@/db/schema');
+ const { eq, leftJoin } = await import('drizzle-orm');
+
+ const biddingInfo = await db
+ .select({
+ id: biddings.id,
+ title: biddings.title,
+ biddingNumber: biddings.biddingNumber,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ biddingType: biddings.biddingType,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ targetPrice: biddings.targetPrice,
+ winnerCount: biddings.winnerCount,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1);
+
+ if (biddingInfo.length === 0) {
+ debugError('[BiddingClosureMapper] 입찰 정보를 찾을 수 없음');
+ throw new Error('입찰 정보를 찾을 수 없습니다');
+ }
+
+ const bidding = biddingInfo[0];
+
+ // 2. 입찰 대상 자재 정보 조회
+ const biddingItemsInfo = await db
+ .select({
+ id: prItemsForBidding.id,
+ materialCode: prItemsForBidding.materialNumber,
+ materialCodeName: prItemsForBidding.materialInfo,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ targetUnitPrice: prItemsForBidding.targetUnitPrice,
+ currency: prItemsForBidding.targetCurrency,
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId));
+
+ // 3. 입찰 참여 업체 및 제출 정보 조회
+ const vendorSubmissions = await db
+ .select({
+ vendorId: biddingCompanies.vendorId,
+ vendorName: biddingCompanies.vendorName,
+ vendorCode: biddingCompanies.vendorCode,
+ targetPrice: biddingVendorSubmissions.targetPrice,
+ bidPrice: biddingVendorSubmissions.bidPrice,
+ submitted: biddingVendorSubmissions.submitted,
+ })
+ .from(biddingCompanies)
+ .leftJoin(biddingVendorSubmissions, eq(biddingCompanies.id, biddingVendorSubmissions.biddingCompanyId))
+ .where(eq(biddingCompanies.biddingId, biddingId));
+
+ debugLog('[BiddingClosureMapper] 입찰 정보 조회 완료', {
+ biddingId,
+ itemCount: biddingItemsInfo.length,
+ vendorCount: vendorSubmissions.length,
+ });
+
+ // 기본 정보 매핑
+ const title = bidding.title || '폐찰';
+ const biddingTitle = bidding.title || '';
+ const biddingNumber = bidding.biddingNumber || '';
+ const winnerCount = (bidding.winnerCount || 1).toString();
+ const contractType = bidding.biddingType || '';
+ const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+ const biddingManager = bidding.bidPicName || bidding.supplyPicName || '';
+ const biddingOverview = bidding.itemName || '';
+
+ // 폐찰 사유
+ const closureReason = description;
+
+ // 협력사별 입찰 현황 매핑
+ const vendorVariables: Record<string, string> = {};
+ vendorSubmissions.forEach((vendor, index) => {
+ const num = index + 1;
+ vendorVariables[`협력사_코드_${num}`] = vendor.vendorCode || '';
+ vendorVariables[`협력사명_${num}`] = vendor.vendorName || '';
+ vendorVariables[`응찰유무_${num}`] = vendor.submitted ? '응찰' : '미응찰';
+ vendorVariables[`내정가_${num}`] = vendor.targetPrice ? vendor.targetPrice.toLocaleString() : '';
+ vendorVariables[`입찰가_${num}`] = vendor.bidPrice ? vendor.bidPrice.toLocaleString() : '';
+ vendorVariables[`비율_${num}`] = (vendor.targetPrice && vendor.bidPrice && vendor.targetPrice > 0)
+ ? ((vendor.bidPrice / vendor.targetPrice) * 100).toFixed(2) + '%'
+ : '';
+ });
+
+ // 품목별 입찰 정보 매핑 (간소화 - 첫 번째 품목 기준으로 매핑)
+ const materialVariables: Record<string, string> = {};
+ biddingItemsInfo.forEach((item, index) => {
+ const num = index + 1;
+ materialVariables[`품목코드_${num}`] = item.materialCode || '';
+ materialVariables[`품목명_${num}`] = item.materialCodeName || '';
+ materialVariables[`수량_${num}`] = item.quantity ? item.quantity.toLocaleString() : '';
+ materialVariables[`단위_${num}`] = item.quantityUnit || '';
+ materialVariables[`통화_${num}`] = item.currency || '';
+ materialVariables[`내정가_${num}`] = item.targetUnitPrice ? item.targetUnitPrice.toLocaleString() : '';
+
+ // 각 품목에 대한 협력사별 입찰가 (간소화: 동일 품목에 대한 모든 업체 입찰가 표시)
+ vendorSubmissions.forEach((vendor, vendorIndex) => {
+ const vendorNum = vendorIndex + 1;
+ materialVariables[`협력사코드_${num}`] = vendor.vendorCode || '';
+ materialVariables[`협력사명_${num}`] = vendor.vendorName || '';
+ materialVariables[`입찰가_${num}`] = vendor.bidPrice ? vendor.bidPrice.toLocaleString() : '';
+ });
+ });
+
+ return {
+ 제목: title,
+ 입찰명: biddingTitle,
+ 입찰번호: biddingNumber,
+ 낙찰업체수: winnerCount,
+ 계약구분: contractType,
+ 내정가: targetPrice,
+ 입찰담당자: biddingManager,
+ 입찰개요: biddingOverview,
+ 폐찰_사유: closureReason,
+ ...vendorVariables,
+ ...materialVariables,
+ };
+}
+
+/**
+ * 폐찰 핸들러 (결재 승인 후 실행됨)
+ *
+ * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지)
+ *
+ * @param payload - withApproval()에서 전달한 actionPayload (최소 데이터만)
+ */
+export async function requestBiddingClosureInternal(payload: {
+ biddingId: number;
+ description: string;
+ files?: File[];
+ currentUserId: number; // ✅ 결재 상신한 사용자 ID
+}) {
+ debugLog('[BiddingClosureHandler] 폐찰 핸들러 시작', {
+ biddingId: payload.biddingId,
+ description: payload.description,
+ currentUserId: payload.currentUserId,
+ });
+
+ // ✅ userId 검증: 핸들러에서 userId가 없으면 잘못된 상황 (예외 처리)
+ if (!payload.currentUserId || payload.currentUserId <= 0) {
+ const errorMessage = 'currentUserId가 없습니다. actionPayload에 currentUserId가 포함되지 않았습니다.';
+ debugError('[BiddingClosureHandler]', errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ try {
+ // 1. 입찰 상태를 폐찰로 변경
+ const { default: db } = await import('@/db/db');
+ const { biddings } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ await db
+ .update(biddings)
+ .set({
+ status: 'closed',
+ updatedBy: payload.currentUserId.toString(),
+ updatedAt: new Date(),
+ remarks: payload.description, // 폐찰 사유를 remarks에 저장
+ })
+ .where(eq(biddings.id, payload.biddingId));
+
+ debugSuccess('[BiddingClosureHandler] 폐찰 완료', {
+ biddingId: payload.biddingId,
+ description: payload.description,
+ });
+
+ // 4. 첨부파일들 저장 (evaluation_doc로 저장)
+ if (payload.files && payload.files.length > 0) {
+ const { saveFile } = await import('@/lib/file-stroage');
+ const { biddingDocuments } = await import('@/db/schema');
+
+ for (const file of payload.files) {
+ try {
+ const saveResult = await saveFile({
+ file,
+ directory: `biddings/${payload.biddingId}/closure-documents`,
+ originalName: file.name,
+ userId: payload.currentUserId.toString()
+ })
+
+ if (saveResult.success) {
+ await db.insert(biddingDocuments).values({
+ biddingId: payload.biddingId,
+ documentType: 'evaluation_doc',
+ fileName: saveResult.fileName!,
+ originalFileName: saveResult.originalName!,
+ fileSize: saveResult.fileSize!,
+ mimeType: file.type,
+ filePath: saveResult.publicPath!,
+ title: `폐찰 문서 - ${file.name}`,
+ description: payload.description,
+ isPublic: false,
+ isRequired: false,
+ uploadedBy: payload.currentUserId.toString(),
+ })
+ } else {
+ console.error(`Failed to save closure file: ${file.name}`, saveResult.error)
+ }
+ } catch (error) {
+ console.error(`Error saving closure file: ${file.name}`, error)
+ }
+ }
+ }
+
+
+ return {
+ success: true,
+ biddingId: payload.biddingId,
+ message: `입찰이 폐찰 처리되었습니다.`,
+ };
+ } catch (error) {
+ debugError('[BiddingClosureHandler] 폐찰 중 에러', error);
+ throw error;
+ }
+}
+
+/**
+ * 낙찰 핸들러 (결재 승인 후 실행됨)
+ *
+ * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지)
+ *
+ * @param payload - withApproval()에서 전달한 actionPayload (최소 데이터만)
+ */
+export async function requestBiddingAwardInternal(payload: {
+ biddingId: number;
+ selectionReason: string;
+ currentUserId: number; // ✅ 결재 상신한 사용자 ID
+}) {
+ debugLog('[BiddingAwardHandler] 낙찰 핸들러 시작', {
+ biddingId: payload.biddingId,
+ selectionReason: payload.selectionReason,
+ currentUserId: payload.currentUserId,
+ });
+
+ // ✅ userId 검증: 핸들러에서 userId가 없으면 잘못된 상황 (예외 처리)
+ if (!payload.currentUserId || payload.currentUserId <= 0) {
+ const errorMessage = 'currentUserId가 없습니다. actionPayload에 currentUserId가 포함되지 않았습니다.';
+ debugError('[BiddingAwardHandler]', errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ try {
+ // 기존 awardBidding 함수 로직을 재구성하여 실행
+ const { awardBidding } = await import('@/lib/bidding/detail/service');
+
+ const result = await awardBidding(payload.biddingId, payload.selectionReason, payload.currentUserId.toString());
+
+ if (!result.success) {
+ debugError('[BiddingAwardHandler] 낙찰 처리 실패', result.error);
+ throw new Error(result.error || '낙찰 처리에 실패했습니다.');
+ }
+
+ debugSuccess('[BiddingAwardHandler] 낙찰 완료', {
+ biddingId: payload.biddingId,
+ selectionReason: payload.selectionReason,
+ });
+
+ return {
+ success: true,
+ biddingId: payload.biddingId,
+ message: `입찰이 낙찰 처리되었습니다.`,
+ };
+ } catch (error) {
+ debugError('[BiddingAwardHandler] 낙찰 중 에러', error);
+ throw error;
+ }
+}
+
+/**
+ * 낙찰 데이터를 결재 템플릿 변수로 매핑
+ *
+ * @param payload - 낙찰 데이터
+ * @returns 템플릿 변수 객체 (Record<string, string>)
+ */
+export async function mapBiddingAwardToTemplateVariables(payload: {
+ biddingId: number;
+ selectionReason: string;
+ requestedAt: Date;
+}): Promise<Record<string, string>> {
+ const { biddingId, selectionReason, requestedAt } = payload;
+
+ // 1. 입찰 정보 조회
+ debugLog('[BiddingAwardMapper] 입찰 정보 조회 시작');
+ const { default: db } = await import('@/db/db');
+ const { biddings, prItemsForBidding } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ const biddingInfo = await db
+ .select({
+ id: biddings.id,
+ title: biddings.title,
+ biddingNumber: biddings.biddingNumber,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ biddingType: biddings.biddingType,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ targetPrice: biddings.targetPrice,
+ winnerCount: biddings.winnerCount,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1);
+
+ if (biddingInfo.length === 0) {
+ debugError('[BiddingAwardMapper] 입찰 정보를 찾을 수 없음');
+ throw new Error('입찰 정보를 찾을 수 없습니다');
+ }
+
+ const bidding = biddingInfo[0];
+
+ // 2. 낙찰된 업체 정보 조회
+ const { getAwardedCompanies } = await import('@/lib/bidding/detail/service');
+ const awardedCompanies = await getAwardedCompanies(biddingId);
+
+ // 3. 입찰 대상 자재 정보 조회
+ const biddingItemsInfo = await db
+ .select({
+ id: prItemsForBidding.id,
+ materialNumber: prItemsForBidding.materialNumber,
+ materialInfo: prItemsForBidding.materialInfo,
+ priceUnit: prItemsForBidding.priceUnit,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ totalWeight: prItemsForBidding.totalWeight,
+ weightUnit: prItemsForBidding.weightUnit,
+ targetUnitPrice: prItemsForBidding.targetUnitPrice,
+ currency: prItemsForBidding.targetCurrency,
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId));
+
+ debugLog('[BiddingAwardMapper] 입찰 정보 조회 완료', {
+ biddingId,
+ itemCount: biddingItemsInfo.length,
+ awardedCompanyCount: awardedCompanies.length,
+ });
+
+ // 기본 정보 매핑
+ const title = bidding.title || '낙찰';
+ const biddingTitle = bidding.title || '';
+ const biddingNumber = bidding.biddingNumber || '';
+ const winnerCount = (bidding.winnerCount || 1).toString();
+ const contractType = bidding.biddingType || '';
+ const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+ const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+ const biddingManager = bidding.bidPicName || bidding.supplyPicName || '';
+ const biddingOverview = bidding.itemName || '';
+
+ // 업체 선정 사유
+ const selectionReasonMapped = selectionReason;
+
+ // 낙찰된 업체 정보 매핑
+ const vendorVariables: Record<string, string> = {};
+ awardedCompanies.forEach((company, index) => {
+ const num = index + 1;
+ vendorVariables[`협력사_코드_${num}`] = company.vendorCode || '';
+ vendorVariables[`협력사명_${num}`] = company.companyName || '';
+ vendorVariables[`기업규모_${num}`] = company.companySize || ''; // TODO: 기업규모 정보가 없으므로 빈 값
+ vendorVariables[`연동제희망여부_${num}`] = 'N'; // TODO: 연동제 정보 미개발
+ vendorVariables[`연동제적용여부_${num}`] = 'N'; // TODO: 연동제 정보 미개발
+ vendorVariables[`낙찰유무_${num}`] = '낙찰';
+ vendorVariables[`확정금액_${num}`] = (company.finalQuoteAmount * company.awardRatio / 100).toLocaleString();
+ vendorVariables[`내정액_${num}`] = company.targetPrice ? company.targetPrice.toLocaleString() : '';
+ vendorVariables[`입찰액_${num}`] = company.finalQuoteAmount.toLocaleString();
+ vendorVariables[`비율_${num}`] = company.targetPrice && company.targetPrice > 0
+ ? ((company.finalQuoteAmount / company.targetPrice) * 100).toFixed(2) + '%'
+ : '';
+ });
+
+ // 품목별 입찰 정보 매핑
+ const materialVariables: Record<string, string> = {};
+ biddingItemsInfo.forEach((item, index) => {
+ const num = index + 1;
+ materialVariables[`자재번호_${num}`] = item.materialNumber || '';
+ materialVariables[`자재내역_${num}`] = item.materialInfo || '';
+ materialVariables[`구매단위_${num}`] = item.priceUnit || '';
+ materialVariables[`수량_${num}`] = item.quantity ? item.quantity.toLocaleString() : '';
+ materialVariables[`수량단위_${num}`] = item.quantityUnit || '';
+ materialVariables[`총중량_${num}`] = item.totalWeight ? item.totalWeight.toLocaleString() : '';
+ materialVariables[`중량단위_${num}`] = item.weightUnit || '';
+ materialVariables[`통화_${num}`] = item.currency || '';
+ materialVariables[`내정액_${num}`] = item.targetUnitPrice ? item.targetUnitPrice.toLocaleString() : '';
+
+ // 각 품목에 대한 낙찰 협력사 정보 (낙찰된 업체만 표시)
+ awardedCompanies.forEach((company, companyIndex) => {
+ const companyNum = companyIndex + 1;
+ materialVariables[`협력사명_${num}`] = company.companyName || '';
+ materialVariables[`입찰액_${num}`] = company.finalQuoteAmount.toLocaleString();
+ });
+ });
+
+ return {
+ 제목: title,
+ 입찰명: biddingTitle,
+ 입찰번호: biddingNumber,
+ 낙찰업체수: winnerCount,
+ 계약구분: contractType,
+ 예산: budget,
+ 내정액: targetPrice,
+ 입찰담당자: biddingManager,
+ 입찰개요: biddingOverview,
+ 업체선정사유: selectionReasonMapped,
+ 대상_자재_수: biddingItemsInfo.length.toString(),
+ ...vendorVariables,
+ ...materialVariables,
+ };
+}
diff --git a/lib/bidding/list/bidding-pr-documents-dialog.tsx b/lib/bidding/list/bidding-pr-documents-dialog.tsx
index ad377ee5..9d291ad8 100644
--- a/lib/bidding/list/bidding-pr-documents-dialog.tsx
+++ b/lib/bidding/list/bidding-pr-documents-dialog.tsx
@@ -304,7 +304,7 @@ export function PrDocumentsDialog({
</div>
</TableCell>
<TableCell className="text-xs">
- {item.purchaseUnit || "-"}
+ {item.priceUnit || "-"}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index 2f458873..90abda57 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -201,8 +201,8 @@ export function CreateBiddingDialog() {
materialGroupInfo: '',
materialNumber: '',
materialInfo: '',
- priceUnit: '',
- purchaseUnit: '1',
+ priceUnit: '1',
+ purchaseUnit: 'EA',
materialWeight: '',
wbsCode: '',
wbsName: '',
@@ -427,8 +427,8 @@ export function CreateBiddingDialog() {
materialGroupInfo: '',
materialNumber: '',
materialInfo: '',
- priceUnit: '',
- purchaseUnit: '1',
+ priceUnit: '1',
+ purchaseUnit: 'EA',
materialWeight: '',
wbsCode: '',
wbsName: '',
@@ -471,8 +471,8 @@ export function CreateBiddingDialog() {
prev.map((item) => {
if (item.id === id) {
const updatedItem = { ...item, ...updates }
- // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산
- if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) {
+ // 내정단가, 수량, 중량, 가격단위가 변경되면 내정금액 재계산
+ if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.priceUnit) {
updatedItem.targetAmount = calculateTargetAmount(updatedItem)
}
return updatedItem
@@ -497,17 +497,17 @@ export function CreateBiddingDialog() {
const calculateTargetAmount = (item: PRItemInfo) => {
const unitPrice = parseFloat(item.targetUnitPrice) || 0
- const purchaseUnit = parseFloat(item.purchaseUnit) || 1 // 기본값 1
+ const priceUnit = parseFloat(item.priceUnit) || 1 // 기본값 1
let amount = 0
if (quantityWeightMode === 'quantity') {
const quantity = parseFloat(item.quantity) || 0
- // (수량 / 구매단위) * 내정단가
- amount = (quantity / purchaseUnit) * unitPrice
+ // (수량 / 가격단위) * 내정단가
+ amount = (quantity / priceUnit) * unitPrice
} else {
const weight = parseFloat(item.totalWeight) || 0
- // (중량 / 구매단위) * 내정단가
- amount = (weight / purchaseUnit) * unitPrice
+ // (중량 / 가격단위) * 내정단가
+ amount = (weight / priceUnit) * unitPrice
}
// 소수점 버림
@@ -772,6 +772,7 @@ export function CreateBiddingDialog() {
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">가격단위</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th>
@@ -955,13 +956,48 @@ export function CreateBiddingDialog() {
type="number"
min="1"
step="1"
- placeholder="구매단위"
- value={item.purchaseUnit || ''}
- onChange={(e) => updatePRItem(item.id, { purchaseUnit: e.target.value })}
+ placeholder="가격단위"
+ value={item.priceUnit || ''}
+ onChange={(e) => updatePRItem(item.id, { priceUnit: e.target.value })}
className="h-8 text-xs"
/>
</td>
<td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Select
+ value={item.purchaseUnit || item.quantityUnit || 'EA'}
+ onValueChange={(value) => updatePRItem(item.id, { purchaseUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EA">EA</SelectItem>
+ <SelectItem value="SET">SET</SelectItem>
+ <SelectItem value="LOT">LOT</SelectItem>
+ <SelectItem value="M">M</SelectItem>
+ <SelectItem value="M2">M²</SelectItem>
+ <SelectItem value="M3">M³</SelectItem>
+ </SelectContent>
+ </Select>
+ ) : (
+ <Select
+ value={item.purchaseUnit || item.weightUnit || 'KG'}
+ onValueChange={(value) => updatePRItem(item.id, { purchaseUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KG">KG</SelectItem>
+ <SelectItem value="TON">TON</SelectItem>
+ <SelectItem value="G">G</SelectItem>
+ <SelectItem value="LB">LB</SelectItem>
+ </SelectContent>
+ </Select>
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
<Input
type="number"
min="0"
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 0261ad57..fe37eaea 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -2363,7 +2363,7 @@ export async function updateBiddingSchedule(
.insert(specificationMeetings)
.values({
biddingId,
- meetingDate: specificationMeeting.meetingDate,
+ meetingDate: parseDate(specificationMeeting.meetingDate),
meetingTime: specificationMeeting.meetingTime || null,
location: specificationMeeting.location,
address: specificationMeeting.address || null,
@@ -2545,6 +2545,39 @@ export async function removeBiddingItem(itemId: number) {
}
}
+// 입찰의 PR 아이템 금액 합산하여 bidding 업데이트
+async function updateBiddingAmounts(biddingId: number) {
+ try {
+ // 해당 bidding의 모든 PR 아이템들의 금액 합계 계산
+ const amounts = await db
+ .select({
+ totalTargetAmount: sql<number>`COALESCE(SUM(${prItemsForBidding.targetAmount}), 0)`,
+ totalBudgetAmount: sql<number>`COALESCE(SUM(${prItemsForBidding.budgetAmount}), 0)`,
+ totalActualAmount: sql<number>`COALESCE(SUM(${prItemsForBidding.actualAmount}), 0)`
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ const { totalTargetAmount, totalBudgetAmount, totalActualAmount } = amounts[0]
+
+ // bidding 테이블 업데이트
+ await db
+ .update(biddings)
+ .set({
+ targetPrice: totalTargetAmount,
+ budget: totalBudgetAmount,
+ finalBidPrice: totalActualAmount,
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, biddingId))
+
+ console.log(`Bidding ${biddingId} amounts updated: target=${totalTargetAmount}, budget=${totalBudgetAmount}, actual=${totalActualAmount}`)
+ } catch (error) {
+ console.error('Failed to update bidding amounts:', error)
+ throw error
+ }
+}
+
// PR 아이템 추가 (전체 필드 지원)
export async function addPRItemForBidding(
biddingId: number,
@@ -2620,6 +2653,9 @@ export async function addPRItemForBidding(
hasSpecDocument: item.hasSpecDocument || false,
}).returning()
+ // PR 아이템 금액 합산하여 bidding 업데이트
+ await updateBiddingAmounts(biddingId)
+
revalidatePath(`/evcp/bid/${biddingId}/info`)
revalidatePath(`/evcp/bid/${biddingId}`)
@@ -2653,6 +2689,7 @@ export async function getBiddingVendors(biddingId: number) {
currency: sql<string>`'KRW'`,
invitationStatus: biddingCompanies.invitationStatus,
isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion,
+ businessSize: vendors.businessSize,
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
@@ -3223,7 +3260,7 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId
)
)
.orderBy(asc(vendorsWithTypesView.vendorName));
-
+
return result;
} catch (error) {
@@ -3232,6 +3269,34 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId
}
}
+// 선택된 vendor들의 businessSize 정보를 가져오는 함수
+export async function getVendorsBusinessSize(vendorIds: number[]) {
+ try {
+ if (vendorIds.length === 0) {
+ return {};
+ }
+
+ const result = await db
+ .select({
+ id: vendors.id,
+ businessSize: vendors.businessSize,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ // Map 형태로 변환하여 반환
+ const businessSizeMap: Record<number, string | null> = {};
+ result.forEach(vendor => {
+ businessSizeMap[vendor.id] = vendor.businessSize;
+ });
+
+ return businessSizeMap;
+ } catch (error) {
+ console.error('Error getting vendors business size:', error);
+ return {};
+ }
+}
+
// 차수증가 또는 재입찰 함수
export async function increaseRoundOrRebid(biddingId: number, userId: string | undefined, type: 'round_increase' | 'rebidding') {
if (!userId) {
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index f70e498e..73c2fe21 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -114,7 +114,7 @@ export const createBiddingSchema = z.object({
isUrgent: z.boolean().default(false),
// 구매조직
- purchasingOrganization: z.string().optional(),
+ purchasingOrganization: z.string().min(1, "구매조직을 선택해주세요"),
// 담당자 정보 (개선된 구조)
bidPicId: z.number().int().positive().optional(),
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index efa10af2..22051a13 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -294,10 +294,10 @@ export function PrItemsPricingTable({
<TableHead>자재내역</TableHead>
<TableHead>수량</TableHead>
<TableHead>단위</TableHead>
- <TableHead>구매단위</TableHead>
+ <TableHead>가격단위</TableHead>
<TableHead>중량</TableHead>
<TableHead>중량단위</TableHead>
- <TableHead>가격단위</TableHead>
+ <TableHead>구매단위</TableHead>
<TableHead>SHI 납품요청일</TableHead>
<TableHead>견적단가</TableHead>
<TableHead>견적금액</TableHead>
@@ -336,12 +336,12 @@ export function PrItemsPricingTable({
{item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'}
</TableCell>
<TableCell>{item.quantityUnit || '-'}</TableCell>
- <TableCell>{item.purchaseUnit || '-'}</TableCell>
+ <TableCell>{item.priceUnit || '-'}</TableCell>
<TableCell className="text-right">
{item.totalWeight ? parseFloat(item.totalWeight).toLocaleString() : '-'}
</TableCell>
<TableCell>{item.weightUnit || '-'}</TableCell>
- <TableCell>{item.priceUnit || '-'}</TableCell>
+ <TableCell>{item.purchaseUnit || '-'}</TableCell>
<TableCell>
{item.requestedDeliveryDate ?
formatDate(item.requestedDeliveryDate, 'KR') : '-'
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index 0215bcb6..504fc916 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -854,7 +854,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label>
{(() => {
const now = new Date()
- const deadline = new Date(biddingDetail.submissionEndDate)
+ const deadline = new Date(biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' '))
const isExpired = deadline < now
const timeLeft = deadline.getTime() - now.getTime()
const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24))
@@ -873,7 +873,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<Calendar className="w-5 h-5" />
<span className="font-medium">제출 마감일:</span>
<span className="text-lg font-semibold">
- {formatDate(biddingDetail.submissionEndDate, 'KR')}
+ {biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')}
</span>
</div>
{isExpired ? (
@@ -1025,7 +1025,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
{/* <div>
- <Label className="text-muted-foreground">연동제 적용</Label>
+ <Label className="text-muted-foreground">하도급법 적용여부</Label>
<div className="mt-1 p-3 bg-muted rounded-md">
<p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p>
</div>
@@ -1179,7 +1179,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<CardContent className="space-y-4">
{/* 공통 필드 - 품목등의 명칭 */}
<div className="space-y-2">
- <Label htmlFor="itemName">품목등의 명칭 *</Label>
+ <Label htmlFor="itemName">물품등의 명칭 *</Label>
<Input
id="itemName"
value={priceAdjustmentForm.itemName}
@@ -1205,7 +1205,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
<div className="space-y-2">
- <Label htmlFor="adjustmentRatio">연동 비율 (%) *</Label>
+ <Label htmlFor="adjustmentRatio">반영비율 (%) *</Label>
<Input
id="adjustmentRatio"
type="number"
@@ -1229,7 +1229,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
<div className="space-y-2">
- <Label htmlFor="referenceDate">기준시점 *</Label>
+ <Label htmlFor="referenceDate">원재료 기준 가격의 변동률 산정을 위한 기준시점 *</Label>
<Input
id="referenceDate"
type="date"
@@ -1240,7 +1240,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
<div className="space-y-2">
- <Label htmlFor="comparisonDate">비교시점 *</Label>
+ <Label htmlFor="comparisonDate">원재료 기준 가격의 변동률 산정을 위한 비교시점 *</Label>
<Input
id="comparisonDate"
type="date"
@@ -1251,7 +1251,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
<div className="space-y-2">
- <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자 *</Label>
+ <Label htmlFor="contractorWriter">수탁기업(협력사)작성자 *</Label>
<Input
id="contractorWriter"
value={priceAdjustmentForm.contractorWriter}
@@ -1322,7 +1322,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
<div className="space-y-2">
- <Label htmlFor="priceAdjustmentNotes">기타 사항</Label>
+ <Label htmlFor="priceAdjustmentNotes">기타사항</Label>
<Textarea
id="priceAdjustmentNotes"
value={priceAdjustmentForm.notes}
@@ -1376,6 +1376,15 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</CardContent>
</Card>
)}
+
+ {/* 참고 경고문 */}
+ <div className="text-xs text-red-600 space-y-2 bg-red-50 p-3 rounded-md border border-red-200 mt-4">
+ <p className="font-medium">※ 참고사항</p>
+ <div className="space-y-1">
+ <p>• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.</p>
+ <p>• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.</p>
+ </div>
+ </div>
</>
)}
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index 63d097c0..8cbddb3d 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -169,6 +169,19 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
}
const handleView = () => {
+ // 입찰기간 체크 (현 시간 기준으로 입찰기간 시작 전이면 접근 불가)
+ const now = new Date()
+ const startDate = row.original.submissionStartDate ? new Date(row.original.submissionStartDate) : null
+ const endDate = row.original.submissionEndDate ? new Date(row.original.submissionEndDate) : null
+
+ if (startDate && now < startDate) {
+ toast.warning('입찰기간 전 접근 제한', {
+ description: `입찰기간이 아직 시작되지 않았습니다. 입찰 시작일: ${format(startDate, "yyyy-MM-dd HH:mm")}`,
+ duration: 5000,
+ })
+ return
+ }
+
// 사양설명회 체크
if (!checkSpecificationMeeting()) {
return
diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx
index c6710d7b..be19f738 100644
--- a/lib/rfq-last/quotation-compare-view.tsx
+++ b/lib/rfq-last/quotation-compare-view.tsx
@@ -119,8 +119,6 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
{ value: 'construction', label: '공사' },
{ value: 'service', label: '용역' },
{ value: 'lease', label: '임차' },
- { value: 'steel_stock', label: '형강스톡' },
- { value: 'piping', label: '배관' },
{ value: 'transport', label: '운송' },
{ value: 'waste', label: '폐기물' },
{ value: 'sale', label: '매각' }
diff --git a/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx
index 41592e46..463a36c9 100644
--- a/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx
+++ b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx
@@ -805,7 +805,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP
{/* 공통 필드 - 품목등의 명칭 */}
{watch("priceAdjustmentForm.priceAdjustmentResponse") !== null && watch("priceAdjustmentForm.priceAdjustmentResponse") !== undefined && (
<div className="space-y-2">
- <Label htmlFor="priceAdjustmentForm.itemName">품목등의 명칭 *</Label>
+ <Label htmlFor="priceAdjustmentForm.itemName">물품등의 명칭 *</Label>
<Input
id="priceAdjustmentForm.itemName"
{...register("priceAdjustmentForm.itemName")}
@@ -830,7 +830,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP
</div>
<div className="space-y-2">
- <Label htmlFor="priceAdjustmentForm.adjustmentRatio">연동 비율 (%) *</Label>
+ <Label htmlFor="priceAdjustmentForm.adjustmentRatio">반영비율 (%) *</Label>
<Input
id="priceAdjustmentForm.adjustmentRatio"
type="number"
@@ -852,7 +852,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP
</div>
<div className="space-y-2">
- <Label htmlFor="priceAdjustmentForm.referenceDate">기준시점 *</Label>
+ <Label htmlFor="priceAdjustmentForm.referenceDate">원재료 기준 가격의 변동률 산정을 위한 기준시점 *</Label>
<Input
id="priceAdjustmentForm.referenceDate"
type="date"
@@ -862,7 +862,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP
</div>
<div className="space-y-2">
- <Label htmlFor="priceAdjustmentForm.comparisonDate">비교시점 *</Label>
+ <Label htmlFor="priceAdjustmentForm.comparisonDate">원재료 기준 가격의 변동률 산정을 위한 비교시점 *</Label>
<Input
id="priceAdjustmentForm.comparisonDate"
type="date"
@@ -872,7 +872,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP
</div>
<div className="space-y-2">
- <Label htmlFor="priceAdjustmentForm.contractorWriter">수탁기업(협력사) 작성자 *</Label>
+ <Label htmlFor="priceAdjustmentForm.contractorWriter">수탁기업(협력사)작성자 *</Label>
<Input
id="priceAdjustmentForm.contractorWriter"
{...register("priceAdjustmentForm.contractorWriter")}
@@ -937,7 +937,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP
</div>
<div className="space-y-2">
- <Label htmlFor="priceAdjustmentForm.notes">기타 사항</Label>
+ <Label htmlFor="priceAdjustmentForm.notes">기타사항</Label>
<Textarea
id="priceAdjustmentForm.notes"
{...register("priceAdjustmentForm.notes")}
@@ -963,7 +963,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP
</div>
<div className="space-y-2">
- <Label htmlFor="priceAdjustmentForm.contractorWriterNonApplicable">수탁기업(협력사) 작성자 *</Label>
+ <Label htmlFor="priceAdjustmentForm.contractorWriterNonApplicable">수탁기업(협력사)작성자 *</Label>
<Input
id="priceAdjustmentForm.contractorWriterNonApplicable"
{...register("priceAdjustmentForm.contractorWriter")}
@@ -988,6 +988,15 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP
</CardContent>
</Card>
)}
+
+ {/* 참고 경고문 */}
+ <div className="text-xs text-red-600 space-y-2 bg-red-50 p-3 rounded-md border border-red-200 mt-4">
+ <p className="font-medium">※ 참고사항</p>
+ <div className="space-y-1">
+ <p>• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.</p>
+ <p>• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.</p>
+ </div>
+ </div>
</>
)}
</CardContent>
diff --git a/lib/rfq-last/vendor/price-adjustment-dialog.tsx b/lib/rfq-last/vendor/price-adjustment-dialog.tsx
index b7fd48a6..56cf5bb0 100644
--- a/lib/rfq-last/vendor/price-adjustment-dialog.tsx
+++ b/lib/rfq-last/vendor/price-adjustment-dialog.tsx
@@ -95,7 +95,7 @@ export function PriceAdjustmentDialog({
<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>
+ <label className="text-xs text-gray-500">물품등의 명칭</label>
<p className="text-sm font-medium">{data.itemName || '-'}</p>
</div>
<div>
@@ -182,17 +182,17 @@ export function PriceAdjustmentDialog({
</div>
<div className="grid grid-cols-2 gap-4">
<div>
- <label className="text-xs text-gray-500">기준시점</label>
+ <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>
+ <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>
+ <label className="text-xs text-gray-500">반영비율</label>
<p className="text-sm font-medium">
{data.adjustmentRatio}%
</p>
@@ -224,12 +224,12 @@ export function PriceAdjustmentDialog({
</div>
</div>
<div>
- <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <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>
+ <label className="text-xs text-gray-500">기타사항</label>
<p className="text-sm font-medium whitespace-pre-wrap">
{data.notes}
</p>
@@ -260,6 +260,17 @@ export function PriceAdjustmentDialog({
<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/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index 28b281f4..72f03dc3 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -287,7 +287,13 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
const result = await sendTechSalesRfqToVendors({
rfqId: selectedRfqId,
vendorIds: vendorIds as number[],
- selectedContacts: selectedContacts
+ selectedContacts: selectedContacts,
+ currentUser: {
+ id: Number(session.data.user.id),
+ epId: session.data.user.epId || null,
+ name: session.data.user.name || undefined,
+ email: session.data.user.email || undefined,
+ },
});
if (result.success) {
@@ -308,7 +314,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
} finally {
setIsSendingRfq(false);
}
- }, [selectedRfqId, selectedRows, handleRefreshData]);
+ }, [selectedRfqId, selectedRows, handleRefreshData, session.data?.user]);
// 벤더 선택 핸들러 추가
const [isAcceptingVendors, setIsAcceptingVendors] = useState(false);
diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx
deleted file mode 100644
index 82f83b7c..00000000
--- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx
+++ /dev/null
@@ -1,710 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { z } from "zod"
-import { useForm, useFieldArray } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
- SheetDescription,
- SheetFooter,
- SheetClose,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription
-} from "@/components/ui/form"
-import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
-import { toast } from "sonner"
-import { Badge } from "@/components/ui/badge"
-
-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 prettyBytes from "pretty-bytes"
-import { formatDate } from "@/lib/utils"
-import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
-import { useSession } from "next-auth/react"
-import { ApprovalPreviewDialog } from "@/lib/approval/client"
-import { ApplicationReasonDialog } from "@/lib/rfq-last/vendor/application-reason-dialog"
-import { requestRfqResendWithDrmApproval } from "@/lib/techsales-rfq/approval-actions"
-import { mapTechSalesRfqSendToTemplateVariables } from "@/lib/techsales-rfq/approval-handlers"
-
-const MAX_FILE_SIZE = 6e8 // 600MB
-
-/** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */
-export interface ExistingTechSalesAttachment {
- id: number
- techSalesRfqId: number
- fileName: string
- originalFileName: string
- filePath: string
- fileSize?: number
- fileType?: string
- attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
- description?: string
- createdBy: number
- createdAt: Date
-}
-
-/** 새로 업로드할 파일 */
-const newUploadSchema = z.object({
- fileObj: z.any().optional(), // 실제 File
- attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"),
- description: z.string().optional(),
-})
-
-/** 기존 첨부 (react-hook-form에서 관리) */
-const existingAttachSchema = z.object({
- id: z.number(),
- techSalesRfqId: z.number(),
- fileName: z.string(),
- originalFileName: z.string(),
- filePath: z.string(),
- fileSize: z.number().optional(),
- fileType: z.string().optional(),
- attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]),
- description: z.string().optional(),
- createdBy: z.number(),
- createdAt: z.custom<Date>(),
-})
-
-/** RHF 폼 전체 스키마 */
-const attachmentsFormSchema = z.object({
- techSalesRfqId: z.number().int(),
- existing: z.array(existingAttachSchema),
- newUploads: z.array(newUploadSchema),
-})
-
-type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema>
-
-// TechSalesRfq 타입 (간단 버전)
-interface TechSalesRfq {
- id: number
- rfqCode: string | null
- status: string
- // 필요한 다른 필드들...
-}
-
-interface TechSalesRfqAttachmentsSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- defaultAttachments?: ExistingTechSalesAttachment[]
- rfq: TechSalesRfq | null
- /** 첨부파일 타입 */
- attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
- /** 읽기 전용 모드 (벤더용) */
- readOnly?: boolean
- /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
- // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
-
-}
-
-export function TechSalesRfqAttachmentsSheet({
- defaultAttachments = [],
- // onAttachmentsUpdated,
- rfq,
- attachmentType = "RFQ_COMMON",
- readOnly = false,
- ...props
-}: TechSalesRfqAttachmentsSheetProps) {
- const [isPending, setIsPending] = React.useState(false)
- const session = useSession()
-
- // 재발송 결재 관련 상태
- const [showResendApprovalDialog, setShowResendApprovalDialog] = React.useState(false)
- const [showApplicationReasonDialog, setShowApplicationReasonDialog] = React.useState(false)
- const [resendApprovalData, setResendApprovalData] = React.useState<{
- rfqId: number
- drmFiles: Array<{
- file: File
- attachmentType: string
- description?: string
- }>
- } | null>(null)
- const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
- templateVariables: Record<string, string>
- applicationReason: string
- } | null>(null)
-
- // 파일 다운로드 핸들러
- const handleDownloadClick = React.useCallback(async (filePath: string, fileName: string) => {
- try {
- const { downloadFile } = await import('@/lib/file-download')
- await downloadFile(filePath, fileName, {
- showToast: true,
- onError: (error) => {
- console.error('다운로드 오류:', error)
- toast.error(error)
- },
- onSuccess: (fileName, fileSize) => {
- console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
- }
- })
- } catch (error) {
- console.error('다운로드 오류:', error)
- toast.error('파일 다운로드 중 오류가 발생했습니다.')
- }
- }, [])
- // 첨부파일 타입별 제목과 설명 설정
- const attachmentConfig = React.useMemo(() => {
- switch (attachmentType) {
- case "TBE_RESULT":
- return {
- title: "TBE 결과 첨부파일",
- description: "기술 평가(TBE) 결과 파일을 관리합니다.",
- fileTypeLabel: "TBE 결과",
- canEdit: !readOnly
- }
- case "CBE_RESULT":
- return {
- title: "CBE 결과 첨부파일",
- description: "상업성 평가(CBE) 결과 파일을 관리합니다.",
- fileTypeLabel: "CBE 결과",
- canEdit: !readOnly
- }
- default: // RFQ_COMMON
- return {
- title: "RFQ 첨부파일",
- description: readOnly ? "RFQ 공통 첨부파일을 조회합니다." : "RFQ 공통 첨부파일을 관리합니다.",
- fileTypeLabel: "공통",
- canEdit: !readOnly
- }
- }
- }, [attachmentType, readOnly])
-
- // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
- // const isEditable = React.useMemo(() => {
- // if (!rfq) return false
- // return attachmentConfig.canEdit
- // }, [rfq, attachmentConfig.canEdit])
-
- const form = useForm<AttachmentsFormValues>({
- resolver: zodResolver(attachmentsFormSchema),
- defaultValues: {
- techSalesRfqId: rfq?.id || 0,
- existing: defaultAttachments.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType,
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- })),
- newUploads: [],
- },
- })
-
- // useFieldArray for existing and new uploads
- const {
- fields: existingFields,
- remove: removeExisting,
- } = useFieldArray({
- control: form.control,
- name: "existing",
- })
-
- const {
- fields: newUploadFields,
- append: appendNewUpload,
- remove: removeNewUpload,
- } = useFieldArray({
- control: form.control,
- name: "newUploads",
- })
-
- // Reset form when defaultAttachments changes
- React.useEffect(() => {
- if (defaultAttachments) {
- form.reset({
- techSalesRfqId: rfq?.id || 0,
- existing: defaultAttachments.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType,
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- })),
- newUploads: [],
- })
- }
- }, [defaultAttachments, rfq?.id, form])
-
- // Handle dropzone accept
- const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => {
- acceptedFiles.forEach((file) => {
- appendNewUpload({
- fileObj: file,
- attachmentType: "RFQ_COMMON",
- description: "",
- })
- })
- }, [appendNewUpload])
-
- // Handle dropzone reject
- const handleDropRejected = React.useCallback(() => {
- toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.")
- }, [])
-
- // Handle remove existing attachment
- const handleRemoveExisting = React.useCallback((index: number) => {
- removeExisting(index)
- }, [removeExisting])
-
- // Handle form submission
- const onSubmit = async (data: AttachmentsFormValues) => {
- if (!rfq) {
- toast.error("RFQ 정보를 찾을 수 없습니다.")
- return
- }
-
- setIsPending(true)
- try {
- // 삭제할 첨부파일 ID 수집
- const deleteAttachmentIds = defaultAttachments
- .filter((original) => !data.existing.find(existing => existing.id === original.id))
- .map(attachment => attachment.id)
-
- // 새 파일 정보 수집
- const newFiles = data.newUploads
- .filter(upload => upload.fileObj)
- .map(upload => ({
- file: upload.fileObj as File,
- attachmentType: attachmentType,
- description: upload.description,
- }))
-
- // 실제 API 호출
- const result = await processTechSalesRfqAttachments({
- techSalesRfqId: rfq.id,
- newFiles,
- deleteAttachmentIds,
- createdBy: parseInt(session.data?.user.id || "0"),
- })
-
- if (result.error) {
- // DRM 파일 추가로 인한 재발송 결재 필요
- if (result.error === "DRM_FILE_ADDED_TO_SENT_RFQ") {
- // DRM 파일만 필터링
- const drmFiles = newFiles.filter((_, index) => {
- // DRM 파일 검출은 서버에서 이미 완료되었으므로, 업로드된 파일 중 DRM 파일만 추출
- // 실제로는 서버에서 반환된 정보를 사용해야 하지만, 여기서는 업로드된 파일을 그대로 사용
- return true // 임시로 모든 새 파일을 DRM 파일로 간주 (실제로는 서버에서 필터링 필요)
- })
-
- setResendApprovalData({
- rfqId: rfq.id,
- drmFiles: newFiles, // 모든 새 파일을 DRM 파일로 간주
- })
- setShowApplicationReasonDialog(true)
- setIsPending(false)
- return
- } else {
- toast.error(result.error)
- return
- }
- }
-
- // 성공 메시지 표시 (업로드된 파일 수 포함)
- const uploadedCount = newFiles.length
- const deletedCount = deleteAttachmentIds.length
-
- let successMessage = "첨부파일이 저장되었습니다."
- if (uploadedCount > 0 && deletedCount > 0) {
- successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료`
- } else if (uploadedCount > 0) {
- successMessage = `${uploadedCount}개 파일이 업로드되었습니다.`
- } else if (deletedCount > 0) {
- successMessage = `${deletedCount}개 파일이 삭제되었습니다.`
- }
-
- toast.success(successMessage)
-
- // 다이얼로그 자동 닫기
- props.onOpenChange?.(false)
-
- // // 즉시 첨부파일 목록 새로고침
- // const refreshResult = await getTechSalesRfqAttachments(rfq.id)
- // if (refreshResult.error) {
- // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
- // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
- // } else {
- // // 새로운 첨부파일 목록으로 폼 업데이트
- // const refreshedAttachments = refreshResult.data.map(att => ({
- // id: att.id,
- // techSalesRfqId: att.techSalesRfqId || rfq.id,
- // fileName: att.fileName,
- // originalFileName: att.originalFileName,
- // filePath: att.filePath,
- // fileSize: att.fileSize,
- // fileType: att.fileType,
- // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
- // description: att.description,
- // createdBy: att.createdBy,
- // createdAt: att.createdAt,
- // }))
-
- // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
- // form.reset({
- // techSalesRfqId: rfq.id,
- // existing: refreshedAttachments.map(att => ({
- // ...att,
- // fileSize: att.fileSize || undefined,
- // fileType: att.fileType || undefined,
- // description: att.description || undefined,
- // })),
- // newUploads: [],
- // })
-
- // // 즉시 UI 업데이트를 위한 추가 피드백
- // if (uploadedCount > 0) {
- // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
- // }
- // }
-
- // // 콜백으로 상위 컴포넌트에 변경사항 알림
- // const newAttachmentCount = refreshResult.error ?
- // (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
- // refreshResult.data.length
- // onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
-
- } catch (error) {
- console.error("첨부파일 저장 오류:", error)
- toast.error("첨부파일 저장 중 오류가 발생했습니다.")
- } finally {
- setIsPending(false)
- }
- }
-
- // 신청사유 입력 완료 핸들러
- const handleApplicationReasonConfirm = React.useCallback(async (reason: string) => {
- if (!resendApprovalData) {
- toast.error("결재 데이터가 없습니다.")
- return
- }
-
- try {
- // 템플릿 변수 생성 (신청사유 포함)
- const templateVariables = await mapTechSalesRfqSendToTemplateVariables({
- attachments: resendApprovalData.drmFiles.map(f => ({
- fileName: f.file.name,
- fileSize: f.file.size,
- })),
- vendorNames: [], // 기존 벤더 목록은 후처리에서 조회
- applicationReason: reason,
- })
-
- // 결재 미리보기 데이터 업데이트
- setApprovalPreviewData({
- templateVariables,
- applicationReason: reason,
- })
-
- // 신청사유 다이얼로그 닫고 결재 미리보기 열기
- setShowApplicationReasonDialog(false)
- setShowResendApprovalDialog(true)
- } catch (error) {
- console.error("템플릿 변수 생성 실패:", error)
- toast.error("결재 문서 생성에 실패했습니다.")
- }
- }, [resendApprovalData])
-
- // 결재 미리보기 확인 핸들러
- const handleApprovalConfirm = React.useCallback(async (approvalData: {
- approvers: string[]
- title: string
- description?: string
- }) => {
- if (!resendApprovalData || !approvalPreviewData || !session?.data?.user) {
- toast.error("결재 데이터가 없습니다.")
- return
- }
-
- try {
- const result = await requestRfqResendWithDrmApproval({
- rfqId: resendApprovalData.rfqId,
- rfqCode: rfq?.rfqCode || undefined,
- drmFiles: resendApprovalData.drmFiles,
- applicationReason: approvalPreviewData.applicationReason,
- currentUser: {
- id: Number(session.data.user.id),
- epId: session.data.user.epId || null,
- name: session.data.user.name || undefined,
- email: session.data.user.email || undefined,
- },
- approvers: approvalData.approvers,
- })
-
- if (result.success) {
- toast.success(result.message)
- setShowResendApprovalDialog(false)
- setResendApprovalData(null)
- setApprovalPreviewData(null)
- props.onOpenChange?.(false)
- }
- } catch (error) {
- console.error("재발송 결재 상신 실패:", error)
- toast.error(error instanceof Error ? error.message : "재발송 결재 상신에 실패했습니다.")
- }
- }, [resendApprovalData, approvalPreviewData, session, rfq, props])
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
- <SheetHeader className="text-left">
- <SheetTitle>{attachmentConfig.title}</SheetTitle>
- <SheetDescription>
- <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
- <div className="mt-1">{attachmentConfig.description}</div>
- {!attachmentConfig.canEdit && (
- <div className="mt-2 flex items-center gap-2 text-amber-600">
- <AlertCircle className="h-4 w-4" />
- <span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
- </div>
- )}
- </SheetDescription>
- </SheetHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-6">
- {/* 1) Existing attachments */}
- <div className="grid gap-4">
- <h6 className="font-semibold leading-none tracking-tight">
- 기존 첨부파일 ({existingFields.length}개)
- </h6>
- {existingFields.map((field, index) => {
- const typeLabel = attachmentConfig.fileTypeLabel
- const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
- const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : ""
-
- return (
- <div key={field.id} className="flex items-start justify-between p-3 border rounded-md gap-3">
- <div className="flex-1 min-w-0 overflow-hidden">
- <div className="flex items-center gap-2 mb-1 flex-wrap">
- <p className="text-sm font-medium break-words leading-tight">
- {field.originalFileName || field.fileName}
- </p>
- <Badge variant="outline" className="text-xs shrink-0">
- {typeLabel}
- </Badge>
- </div>
- <p className="text-xs text-muted-foreground">
- {sizeText} • {dateText}
- </p>
- {field.description && (
- <p className="text-xs text-muted-foreground mt-1 break-words">
- {field.description}
- </p>
- )}
- </div>
-
- <div className="flex items-center gap-1 shrink-0">
- {/* Download button */}
- {field.filePath && (
- <Button
- variant="ghost"
- size="icon"
- type="button"
- className="h-8 w-8"
- onClick={() => handleDownloadClick(field.filePath, field.originalFileName || field.fileName)}
- title="다운로드"
- >
- <Download className="h-4 w-4" />
- </Button>
- )}
- {/* Remove button - 편집 가능할 때만 표시 */}
- {attachmentConfig.canEdit && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-8 w-8"
- onClick={() => handleRemoveExisting(index)}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </div>
- </div>
- )
- })}
- </div>
-
- {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
- {attachmentConfig.canEdit ? (
- <>
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={handleDropRejected}
- >
- {({ maxSize }) => (
- <FormField
- control={form.control}
- name="newUploads"
- render={() => (
- <FormItem>
- <FormLabel>새 파일 업로드</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>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
- </Dropzone>
-
- {/* newUpload fields -> FileList */}
- {newUploadFields.length > 0 && (
- <div className="grid gap-4">
- <h6 className="font-semibold leading-none tracking-tight">
- 새 파일 ({newUploadFields.length}개)
- </h6>
- <FileList>
- {newUploadFields.map((field, idx) => {
- const fileObj = form.getValues(`newUploads.${idx}.fileObj`)
- if (!fileObj) return null
-
- const fileName = fileObj.name
- const fileSize = fileObj.size
- return (
- <FileListItem key={field.id}>
- <FileListHeader>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{fileName}</FileListName>
- <FileListDescription>
- {prettyBytes(fileSize)}
- </FileListDescription>
- </FileListInfo>
- <FileListAction onClick={() => removeNewUpload(idx)}>
- <X />
- <span className="sr-only">제거</span>
- </FileListAction>
- </FileListHeader>
-
- </FileListItem>
- )
- })}
- </FileList>
- </div>
- )}
- </>
- ) : (
- <div className="p-3 bg-muted rounded-md flex items-center justify-center">
- <div className="text-center text-sm text-muted-foreground">
- <Eye className="h-4 w-4 mx-auto mb-2" />
- <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p>
- </div>
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- {attachmentConfig.canEdit ? "취소" : "닫기"}
- </Button>
- </SheetClose>
- {attachmentConfig.canEdit && (
- <Button
- type="submit"
- disabled={
- isPending ||
- (
- form.getValues().newUploads.length === 0 &&
- form.getValues().existing.length === defaultAttachments.length &&
- form.getValues().existing.every(existing =>
- defaultAttachments.some(original => original.id === existing.id)
- )
- )
- }
- >
- {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- {isPending ? "저장 중..." : "저장"}
- </Button>
- )}
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
-
- {/* 신청사유 입력 다이얼로그 */}
- {resendApprovalData && (
- <ApplicationReasonDialog
- open={showApplicationReasonDialog}
- onOpenChange={setShowApplicationReasonDialog}
- onConfirm={handleApplicationReasonConfirm}
- vendorCount={0} // 재발송이므로 기존 벤더에게 발송
- attachmentCount={resendApprovalData.drmFiles.length}
- />
- )}
-
- {/* 결재 미리보기 다이얼로그 */}
- {resendApprovalData && session?.data?.user?.epId && approvalPreviewData && (
- <ApprovalPreviewDialog
- open={showResendApprovalDialog}
- onOpenChange={setShowResendApprovalDialog}
- templateName="암호화해제 신청"
- variables={approvalPreviewData.templateVariables}
- title={`DRM 파일 재발송 결재 - ${rfq?.rfqCode || 'RFQ'}`}
- currentUser={{
- id: Number(session.data.user.id),
- epId: session.data.user.epId,
- name: session.data.user.name || undefined,
- email: session.data.user.email || undefined,
- }}
- onConfirm={handleApprovalConfirm}
- allowTitleEdit={false}
- />
- )}
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/vendors/bid-history-table/bid-history-table-columns.tsx b/lib/vendors/bid-history-table/bid-history-table-columns.tsx
index b235917f..7afecab4 100644
--- a/lib/vendors/bid-history-table/bid-history-table-columns.tsx
+++ b/lib/vendors/bid-history-table/bid-history-table-columns.tsx
@@ -132,8 +132,6 @@ export function getColumns({ setRowAction, onViewDetails }: GetColumnsProps): Co
construction: "공사",
service: "용역",
lease: "임차",
- steel_stock: "형강스톡",
- piping: "배관",
transport: "운송",
waste: "폐기물",
sale: "매각"
diff --git a/lib/vendors/bid-history-table/bid-history-table.tsx b/lib/vendors/bid-history-table/bid-history-table.tsx
index ec810429..e41db58a 100644
--- a/lib/vendors/bid-history-table/bid-history-table.tsx
+++ b/lib/vendors/bid-history-table/bid-history-table.tsx
@@ -92,8 +92,6 @@ export function VendorBidHistoryTable({ promises, lng }: BidHistoryTableProps) {
{ label: "공사", value: "construction" },
{ label: "용역", value: "service" },
{ label: "임차", value: "lease" },
- { label: "형강스톡", value: "steel_stock" },
- { label: "배관", value: "piping" },
{ label: "운송", value: "transport" },
{ label: "폐기물", value: "waste" },
{ label: "매각", value: "sale" }